diff --git a/Cargo.lock b/Cargo.lock index 67158dc..44e9967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -600,6 +600,28 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gpodder" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "rand", +] + +[[package]] +name = "gpodder_sqlite" +version = "0.1.0" +dependencies = [ + "chrono", + "diesel", + "diesel_migrations", + "gpodder", + "libsqlite3-sys", + "rand", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -659,12 +681,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -926,16 +948,15 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" name = "otter" version = "0.1.0" dependencies = [ - "argon2", "axum", "axum-extra", "chrono", "clap", "cookie", - "diesel", - "diesel_migrations", "figment", - "libsqlite3-sys", + "gpodder", + "gpodder_sqlite", + "http-body-util", "rand", "serde", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 73dc6b1..8f2f346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,25 @@ +[workspace] +members = [ + 'gpodder', + 'gpodder_sqlite' +] + [package] name = "otter" version = "0.1.0" edition = "2021" [dependencies] -argon2 = "0.5.3" +gpodder = { path = "./gpodder" } +gpodder_sqlite = { path = "./gpodder_sqlite" } + axum = { version = "0.8.1", features = ["macros"] } axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } chrono = { version = "0.4.39", features = ["serde"] } clap = { version = "4.5.30", features = ["derive", "env"] } cookie = "0.18.1" -diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } -diesel_migrations = { version = "2.2.0", features = ["sqlite"] } figment = { version = "0.10.19", features = ["env", "toml"] } -libsqlite3-sys = { version = "0.31.0", features = ["bundled"] } +http-body-util = "0.1.3" rand = "0.8.5" serde = { version = "1.0.218", features = ["derive"] } tokio = { version = "1.43.0", features = ["full"] } diff --git a/gpodder/Cargo.lock b/gpodder/Cargo.lock new file mode 100644 index 0000000..dd49cdc --- /dev/null +++ b/gpodder/Cargo.lock @@ -0,0 +1,520 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gpodder" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "rand", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/gpodder/Cargo.toml b/gpodder/Cargo.toml new file mode 100644 index 0000000..650529a --- /dev/null +++ b/gpodder/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "gpodder" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4.39", features = ["serde"] } +argon2 = "0.5.3" +rand = "0.8.5" diff --git a/src/gpodder/mod.rs b/gpodder/src/lib.rs similarity index 99% rename from src/gpodder/mod.rs rename to gpodder/src/lib.rs index c9abd8e..e834bcf 100644 --- a/src/gpodder/mod.rs +++ b/gpodder/src/lib.rs @@ -5,6 +5,7 @@ use std::fmt::Display; use chrono::{DateTime, Utc}; pub use models::*; + pub use repository::GpodderRepository; #[derive(Debug)] diff --git a/src/gpodder/models.rs b/gpodder/src/models.rs similarity index 100% rename from src/gpodder/models.rs rename to gpodder/src/models.rs diff --git a/src/gpodder/repository.rs b/gpodder/src/repository.rs similarity index 100% rename from src/gpodder/repository.rs rename to gpodder/src/repository.rs diff --git a/.env b/gpodder_sqlite/.env similarity index 100% rename from .env rename to gpodder_sqlite/.env diff --git a/gpodder_sqlite/Cargo.lock b/gpodder_sqlite/Cargo.lock new file mode 100644 index 0000000..a140641 --- /dev/null +++ b/gpodder_sqlite/Cargo.lock @@ -0,0 +1,954 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diesel" +version = "2.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470eb10efc8646313634c99bb1593f402a6434cbd86e266770c6e39219adb86a" +dependencies = [ + "diesel_derives", + "libsqlite3-sys", + "r2d2", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93958254b70bea63b4187ff73d10180599d9d8d177071b7f91e6da4e0c0ad55" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diesel_migrations" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +dependencies = [ + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dsl_auto_type" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gpodder" +version = "0.1.0" +dependencies = [ + "argon2", + "chrono", + "rand", +] + +[[package]] +name = "gpodder_sqlite" +version = "0.1.0" +dependencies = [ + "chrono", + "diesel", + "diesel_migrations", + "gpodder", + "rand", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "migrations_internals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" + +[[package]] +name = "time-macros" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/gpodder_sqlite/Cargo.toml b/gpodder_sqlite/Cargo.toml new file mode 100644 index 0000000..7141b6d --- /dev/null +++ b/gpodder_sqlite/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "gpodder_sqlite" +version = "0.1.0" +edition = "2021" + +[dependencies] +gpodder = { path = "../gpodder" } +diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } +diesel_migrations = { version = "2.2.0", features = ["sqlite"] } +tracing = "0.1.41" +chrono = { version = "0.4.39", features = ["serde"] } +rand = "0.8.5" +libsqlite3-sys = { version = "0.31.0", features = ["bundled"] } diff --git a/diesel.toml b/gpodder_sqlite/diesel.toml similarity index 100% rename from diesel.toml rename to gpodder_sqlite/diesel.toml diff --git a/migrations/2025-02-23-095541_initial/down.sql b/gpodder_sqlite/migrations/2025-02-23-095541_initial/down.sql similarity index 100% rename from migrations/2025-02-23-095541_initial/down.sql rename to gpodder_sqlite/migrations/2025-02-23-095541_initial/down.sql diff --git a/migrations/2025-02-23-095541_initial/up.sql b/gpodder_sqlite/migrations/2025-02-23-095541_initial/up.sql similarity index 100% rename from migrations/2025-02-23-095541_initial/up.sql rename to gpodder_sqlite/migrations/2025-02-23-095541_initial/up.sql diff --git a/src/db/mod.rs b/gpodder_sqlite/src/lib.rs similarity index 87% rename from src/db/mod.rs rename to gpodder_sqlite/src/lib.rs index ef3eeb8..4b6ccbb 100644 --- a/src/db/mod.rs +++ b/gpodder_sqlite/src/lib.rs @@ -1,4 +1,4 @@ -pub mod models; +mod models; mod repository; mod schema; @@ -6,13 +6,6 @@ use diesel::connection::InstrumentationEvent; use diesel::r2d2::CustomizeConnection; use diesel::Connection; -pub use models::device::{Device, DeviceType, NewDevice}; -pub use models::device_subscription::{DeviceSubscription, NewDeviceSubscription}; -pub use models::episode_action::{ActionType, EpisodeAction, NewEpisodeAction}; -pub use models::session::Session; -pub use models::sync_group::SyncGroup; -pub use models::user::{NewUser, User}; - pub use repository::SqliteRepository; use diesel::{ @@ -64,6 +57,15 @@ impl From for DbError { } } +impl From for gpodder::AuthErr { + fn from(value: DbError) -> Self { + match value { + DbError::Pool(err) => Self::Other(Box::new(err)), + DbError::Db(err) => Self::Other(Box::new(err)), + } + } +} + #[derive(Debug)] pub struct AddQueryDebugLogs; diff --git a/src/db/models/device.rs b/gpodder_sqlite/src/models/device.rs similarity index 73% rename from src/db/models/device.rs rename to gpodder_sqlite/src/models/device.rs index 2be8f75..25d3052 100644 --- a/src/db/models/device.rs +++ b/gpodder_sqlite/src/models/device.rs @@ -8,11 +8,10 @@ use diesel::{ sql_types::Text, sqlite::{Sqlite, SqliteValue}, }; -use serde::{Deserialize, Serialize}; -use crate::db::{schema::*, DbPool, DbResult}; +use crate::schema::*; -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] +#[derive(Clone, Queryable, Selectable)] #[diesel(table_name = devices)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Device { @@ -24,7 +23,7 @@ pub struct Device { pub sync_group_id: Option, } -#[derive(Deserialize, Insertable)] +#[derive(Insertable)] #[diesel(table_name = devices)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct NewDevice { @@ -34,9 +33,8 @@ pub struct NewDevice { pub type_: DeviceType, } -#[derive(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)] +#[derive(FromSqlRow, Debug, AsExpression, Clone)] #[diesel(sql_type = Text)] -#[serde(rename_all = "lowercase")] pub enum DeviceType { Desktop, Laptop, @@ -46,13 +44,6 @@ pub enum DeviceType { } impl Device { - pub fn for_user(pool: &DbPool, user_id: i64) -> DbResult> { - Ok(devices::dsl::devices - .select(Self::as_select()) - .filter(devices::user_id.eq(user_id)) - .get_results(&mut pool.get()?)?) - } - pub fn device_id_to_id( conn: &mut SqliteConnection, user_id: i64, @@ -82,22 +73,6 @@ impl Device { ) .get_result(conn) } - - pub fn update(&self, pool: &DbPool) -> DbResult<()> { - Ok(diesel::update( - devices::table.filter( - devices::user_id - .eq(self.user_id) - .and(devices::device_id.eq(&self.device_id)), - ), - ) - .set(( - devices::caption.eq(&self.caption), - devices::type_.eq(&self.type_), - )) - .execute(&mut pool.get()?) - .map(|_| ())?) - } } impl NewDevice { @@ -109,13 +84,6 @@ impl NewDevice { type_, } } - - pub fn insert(self, pool: &DbPool) -> DbResult { - Ok(diesel::insert_into(devices::table) - .values(&self) - .returning(Device::as_returning()) - .get_result(&mut pool.get()?)?) - } } impl fmt::Display for DeviceType { diff --git a/src/db/models/device_subscription.rs b/gpodder_sqlite/src/models/device_subscription.rs similarity index 76% rename from src/db/models/device_subscription.rs rename to gpodder_sqlite/src/models/device_subscription.rs index c2541c8..a77d2a3 100644 --- a/src/db/models/device_subscription.rs +++ b/gpodder_sqlite/src/models/device_subscription.rs @@ -1,9 +1,8 @@ use diesel::prelude::*; -use serde::{Deserialize, Serialize}; -use crate::db::schema::*; +use crate::schema::*; -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] +#[derive(Clone, Queryable, Selectable)] #[diesel(table_name = device_subscriptions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct DeviceSubscription { @@ -14,7 +13,7 @@ pub struct DeviceSubscription { pub deleted: bool, } -#[derive(Deserialize, Insertable)] +#[derive(Insertable)] #[diesel(table_name = device_subscriptions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct NewDeviceSubscription { diff --git a/src/db/models/episode_action.rs b/gpodder_sqlite/src/models/episode_action.rs similarity index 90% rename from src/db/models/episode_action.rs rename to gpodder_sqlite/src/models/episode_action.rs index b7fd89e..faef14f 100644 --- a/src/db/models/episode_action.rs +++ b/gpodder_sqlite/src/models/episode_action.rs @@ -9,11 +9,10 @@ use diesel::{ sqlite::{Sqlite, SqliteValue}, Selectable, }; -use serde::{Deserialize, Serialize}; -use crate::db::schema::*; +use crate::schema::*; -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] +#[derive(Clone, Queryable, Selectable)] #[diesel(table_name = episode_actions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct EpisodeAction { @@ -30,7 +29,7 @@ pub struct EpisodeAction { pub total: Option, } -#[derive(Deserialize, Insertable)] +#[derive(Insertable)] #[diesel(table_name = episode_actions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct NewEpisodeAction { @@ -46,9 +45,8 @@ pub struct NewEpisodeAction { pub total: Option, } -#[derive(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)] +#[derive(FromSqlRow, Debug, AsExpression, Clone)] #[diesel(sql_type = Text)] -#[serde(rename_all = "lowercase")] pub enum ActionType { New, Download, diff --git a/src/db/models/mod.rs b/gpodder_sqlite/src/models/mod.rs similarity index 100% rename from src/db/models/mod.rs rename to gpodder_sqlite/src/models/mod.rs diff --git a/gpodder_sqlite/src/models/session.rs b/gpodder_sqlite/src/models/session.rs new file mode 100644 index 0000000..53fe6d9 --- /dev/null +++ b/gpodder_sqlite/src/models/session.rs @@ -0,0 +1,60 @@ +use diesel::prelude::*; + +use crate::schema::*; + +#[derive(Clone, Queryable, Selectable, Insertable, Associations)] +#[diesel(belongs_to(super::user::User))] +#[diesel(table_name = sessions)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Session { + pub id: i64, + pub user_id: i64, + pub last_seen: i64, +} + +impl Session { + // pub fn new_for_user(pool: &DbPool, user_id: i64, last_seen: i64) -> DbResult { + // let id: i64 = rand::thread_rng().gen(); + + // Ok(Self { + // id, + // user_id, + // last_seen, + // } + // .insert_into(sessions::table) + // .returning(Self::as_returning()) + // .get_result(&mut pool.get()?)?) + // } + + // pub fn user_from_id(pool: &DbPool, id: i64) -> DbResult> { + // Ok(sessions::dsl::sessions + // .inner_join(users::table) + // .filter(sessions::id.eq(id)) + // .select(User::as_select()) + // .get_result(&mut pool.get()?) + // .optional()?) + // } + + // pub fn user(&self, pool: &DbPool) -> DbResult> { + // Self::user_from_id(pool, self.id) + // } + + // pub fn by_id(pool: &DbPool, id: i64) -> DbResult> { + // Ok(sessions::dsl::sessions + // .find(id) + // .get_result(&mut pool.get()?) + // .optional()?) + // } + + // pub fn remove(self, pool: &DbPool) -> DbResult { + // Self::remove_by_id(pool, self.id) + // } + + // pub fn remove_by_id(pool: &DbPool, id: i64) -> DbResult { + // Ok( + // diesel::delete(sessions::dsl::sessions.filter(sessions::id.eq(id))) + // .execute(&mut pool.get()?)? + // > 0, + // ) + // } +} diff --git a/src/db/models/sync_group.rs b/gpodder_sqlite/src/models/sync_group.rs similarity index 97% rename from src/db/models/sync_group.rs rename to gpodder_sqlite/src/models/sync_group.rs index edeca8c..aad14e3 100644 --- a/src/db/models/sync_group.rs +++ b/gpodder_sqlite/src/models/sync_group.rs @@ -3,7 +3,7 @@ use diesel::{ prelude::*, }; -use crate::db::schema::*; +use crate::schema::*; #[derive(Queryable, Selectable)] #[diesel(table_name = sync_groups)] diff --git a/gpodder_sqlite/src/models/user.rs b/gpodder_sqlite/src/models/user.rs new file mode 100644 index 0000000..b7a15a9 --- /dev/null +++ b/gpodder_sqlite/src/models/user.rs @@ -0,0 +1,47 @@ +use diesel::prelude::*; + +use crate::schema::*; + +#[derive(Clone, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct User { + pub id: i64, + pub username: String, + pub password_hash: String, +} + +#[derive(Insertable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct NewUser { + pub username: String, + pub password_hash: String, +} + +// impl NewUser { +// pub fn new(username: String, password: String) -> Self { +// Self { +// username, +// password_hash: hash_password(&password), +// } +// } +// } + +// impl User { +// pub fn by_username(pool: &DbPool, username: impl AsRef) -> DbResult> { +// Ok(users::dsl::users +// .select(User::as_select()) +// .filter(users::username.eq(username.as_ref())) +// .first(&mut pool.get()?) +// .optional()?) +// } + +// pub fn verify_password(&self, password: impl AsRef) -> bool { +// let password_hash = PasswordHash::new(&self.password_hash).unwrap(); + +// Argon2::default() +// .verify_password(password.as_ref().as_bytes(), &password_hash) +// .is_ok() +// } +// } diff --git a/src/db/repository/auth.rs b/gpodder_sqlite/src/repository/auth.rs similarity index 67% rename from src/db/repository/auth.rs rename to gpodder_sqlite/src/repository/auth.rs index 3aafa05..e9b867a 100644 --- a/src/db/repository/auth.rs +++ b/gpodder_sqlite/src/repository/auth.rs @@ -1,26 +1,16 @@ use chrono::DateTime; use diesel::prelude::*; +use gpodder::AuthErr; use super::SqliteRepository; use crate::{ - db::{self, schema::*}, - gpodder::{self, AuthErr}, + models::{session::Session, user::User}, + schema::*, + DbError, }; -impl From for gpodder::AuthErr { - fn from(value: diesel::r2d2::PoolError) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for gpodder::AuthErr { - fn from(value: diesel::result::Error) -> Self { - Self::Other(Box::new(value)) - } -} - -impl From for gpodder::User { - fn from(value: db::User) -> Self { +impl From for gpodder::User { + fn from(value: User) -> Self { Self { id: value.id, username: value.username, @@ -32,10 +22,11 @@ impl From for gpodder::User { impl gpodder::AuthStore for SqliteRepository { fn get_user(&self, username: &str) -> Result, AuthErr> { Ok(users::table - .select(db::User::as_select()) + .select(User::as_select()) .filter(users::username.eq(username)) - .first(&mut self.pool.get()?) - .optional()? + .first(&mut self.pool.get().map_err(DbError::from)?) + .optional() + .map_err(DbError::from)? .map(gpodder::User::from)) } @@ -43,35 +34,37 @@ impl gpodder::AuthStore for SqliteRepository { match sessions::table .inner_join(users::table) .filter(sessions::id.eq(session_id)) - .select((db::Session::as_select(), db::User::as_select())) - .get_result(&mut self.pool.get()?) + .select((Session::as_select(), User::as_select())) + .get_result(&mut self.pool.get().map_err(DbError::from)?) { Ok((session, user)) => Ok(Some(gpodder::Session { id: session.id, last_seen: DateTime::from_timestamp(session.last_seen, 0).unwrap(), user: user.into(), })), - Err(err) => Err(AuthErr::from(err)), + Err(err) => Err(DbError::from(err).into()), } } fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> { Ok( diesel::delete(sessions::table.filter(sessions::id.eq(session_id))) - .execute(&mut self.pool.get()?) - .map(|_| ())?, + .execute(&mut self.pool.get().map_err(DbError::from)?) + .map(|_| ()) + .map_err(DbError::from)?, ) } fn insert_session(&self, session: &gpodder::Session) -> Result<(), AuthErr> { - Ok(db::Session { + Ok(Session { id: session.id, user_id: session.user.id, last_seen: session.last_seen.timestamp(), } .insert_into(sessions::table) - .execute(&mut self.pool.get()?) - .map(|_| ())?) + .execute(&mut self.pool.get().map_err(DbError::from)?) + .map(|_| ()) + .map_err(DbError::from)?) } fn refresh_session( @@ -81,7 +74,8 @@ impl gpodder::AuthStore for SqliteRepository { ) -> Result<(), AuthErr> { if diesel::update(sessions::table.filter(sessions::id.eq(session.id))) .set(sessions::last_seen.eq(timestamp.timestamp())) - .execute(&mut self.pool.get()?)? + .execute(&mut self.pool.get().map_err(DbError::from)?) + .map_err(DbError::from)? == 0 { Err(AuthErr::UnknownSession) @@ -95,7 +89,8 @@ impl gpodder::AuthStore for SqliteRepository { Ok( diesel::delete(sessions::table.filter(sessions::last_seen.lt(min_last_seen))) - .execute(&mut self.pool.get()?)?, + .execute(&mut self.pool.get().map_err(DbError::from)?) + .map_err(DbError::from)?, ) } } diff --git a/gpodder_sqlite/src/repository/device.rs b/gpodder_sqlite/src/repository/device.rs new file mode 100644 index 0000000..d69caeb --- /dev/null +++ b/gpodder_sqlite/src/repository/device.rs @@ -0,0 +1,298 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; +use diesel::{alias, dsl::not, prelude::*}; +use gpodder::AuthErr; + +use super::SqliteRepository; +use crate::{ + models::{ + device::{Device, DeviceType, NewDevice}, + sync_group::SyncGroup, + }, + schema::*, + DbError, +}; + +impl From for gpodder::DeviceType { + fn from(value: DeviceType) -> Self { + match value { + DeviceType::Desktop => Self::Desktop, + DeviceType::Laptop => Self::Laptop, + DeviceType::Mobile => Self::Mobile, + DeviceType::Server => Self::Server, + DeviceType::Other => Self::Other, + } + } +} + +impl From for DeviceType { + fn from(value: gpodder::DeviceType) -> Self { + match value { + gpodder::DeviceType::Desktop => Self::Desktop, + gpodder::DeviceType::Laptop => Self::Laptop, + gpodder::DeviceType::Mobile => Self::Mobile, + gpodder::DeviceType::Server => Self::Server, + gpodder::DeviceType::Other => Self::Other, + } + } +} + +impl gpodder::DeviceRepository for SqliteRepository { + fn devices_for_user( + &self, + user: &gpodder::User, + ) -> Result, gpodder::AuthErr> { + (|| { + Ok::<_, DbError>( + devices::table + .select(Device::as_select()) + .filter(devices::user_id.eq(user.id)) + .get_results(&mut self.pool.get()?)? + .into_iter() + .map(|d| gpodder::Device { + id: d.device_id, + caption: d.caption, + r#type: d.type_.into(), + // TODO implement subscription count + subscriptions: 0, + }) + .collect(), + ) + })() + .map_err(AuthErr::from) + } + + fn update_device_info( + &self, + user: &gpodder::User, + device_id: &str, + patch: gpodder::DevicePatch, + ) -> Result<(), gpodder::AuthErr> { + (|| { + if let Some(mut device) = devices::table + .select(Device::as_select()) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq(device_id)), + ) + .get_result(&mut self.pool.get()?) + .optional()? + { + if let Some(caption) = patch.caption { + device.caption = caption; + } + + if let Some(type_) = patch.r#type { + device.type_ = type_.into(); + } + + diesel::update(devices::table.filter(devices::id.eq(device.id))) + .set(( + devices::caption.eq(&device.caption), + devices::type_.eq(&device.type_), + )) + .execute(&mut self.pool.get()?)?; + } else { + let device = NewDevice { + device_id: device_id.to_string(), + user_id: user.id, + caption: patch.caption.unwrap_or(String::new()), + type_: patch.r#type.unwrap_or(gpodder::DeviceType::Other).into(), + }; + + diesel::insert_into(devices::table) + .values(device) + .execute(&mut self.pool.get()?)?; + } + + Ok::<_, DbError>(()) + })() + .map_err(AuthErr::from) + } + + fn merge_sync_groups( + &self, + user: &gpodder::User, + device_ids: Vec<&str>, + ) -> Result { + (|| { + let conn = &mut self.pool.get()?; + + conn.transaction(|conn| { + let devices: Vec<(i64, Option)> = devices::table + .select((devices::id, devices::sync_group_id)) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq_any(device_ids)), + ) + .get_results(conn)?; + + let mut sync_group_ids: Vec = devices + .iter() + .filter_map(|(_, group_id)| *group_id) + .collect(); + + // Remove any duplicates, giving us each sync group ID once + sync_group_ids.sort(); + sync_group_ids.dedup(); + + // If any of the devices are already in a sync group, we reuse the first one we find. + // Otherwise, we generate a new one. + let sync_group_id = if let Some(id) = sync_group_ids.pop() { + id + } else { + SyncGroup::new(conn)?.id + }; + + // Move all devices in the other sync groups into the new sync group + diesel::update( + devices::table.filter(devices::sync_group_id.eq_any(sync_group_ids.iter())), + ) + .set(devices::sync_group_id.eq(sync_group_id)) + .execute(conn)?; + + // Add the non-synchronized devices into the new sync group + let unsynced_device_ids = + devices.iter().filter_map( + |(id, group_id)| if group_id.is_none() { Some(id) } else { None }, + ); + + diesel::update(devices::table.filter(devices::id.eq_any(unsynced_device_ids))) + .set(devices::sync_group_id.eq(sync_group_id)) + .execute(conn)?; + + // Remove the other now unused sync groups + diesel::delete(sync_groups::table.filter(sync_groups::id.eq_any(sync_group_ids))) + .execute(conn)?; + + Ok::<_, DbError>(sync_group_id) + }) + })() + .map_err(AuthErr::from) + } + + fn remove_from_sync_group( + &self, + user: &gpodder::User, + device_ids: Vec<&str>, + ) -> Result<(), gpodder::AuthErr> { + (|| { + let conn = &mut self.pool.get()?; + + diesel::update( + devices::table.filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq_any(device_ids)), + ), + ) + .set(devices::sync_group_id.eq(None::)) + .execute(conn)?; + + // This is in a different transaction on purpose, as the success of this removal shouldn't + // fail the entire query + SyncGroup::remove_unused(conn)?; + + Ok::<_, DbError>(()) + })() + .map_err(AuthErr::from) + } + + fn synchronize_sync_group( + &self, + group_id: i64, + time_changed: DateTime, + ) -> Result<(), gpodder::AuthErr> { + (|| { + let time_changed = time_changed.timestamp(); + let conn = &mut self.pool.get()?; + + conn.transaction(|conn| { + let device_ids: Vec = devices::table + .filter(devices::sync_group_id.eq(group_id)) + .select(devices::id) + .get_results(conn)?; + + // For each device in the group, we get the list of subscriptions not yet in its own + // non-deleted list, and add it to the database + for device_id in device_ids.iter().copied() { + let d1 = alias!(device_subscriptions as d1); + + let own_subscriptions = d1 + .filter( + d1.field(device_subscriptions::device_id) + .eq(device_id) + .and(d1.field(device_subscriptions::deleted).eq(false)), + ) + .select(d1.field(device_subscriptions::podcast_url)); + + let urls_to_add = device_subscriptions::table + .select(device_subscriptions::podcast_url) + .filter( + device_subscriptions::device_id + .eq_any(device_ids.iter()) + .and(device_subscriptions::deleted.eq(false)) + .and(not( + device_subscriptions::podcast_url.eq_any(own_subscriptions) + )), + ) + .distinct() + .load_iter(conn)? + .collect::, _>>()?; + + super::subscription::insert_subscriptions_for_single_device( + conn, + device_id, + urls_to_add.iter(), + time_changed, + )?; + } + + Ok::<_, DbError>(()) + }) + })() + .map_err(AuthErr::from) + } + + fn devices_by_sync_group( + &self, + user: &gpodder::User, + ) -> Result<(Vec, Vec>), gpodder::AuthErr> { + (|| { + let mut not_synchronized = Vec::new(); + let mut synchronized = Vec::new(); + + let conn = &mut self.pool.get()?; + let mut devices = devices::table + .select((devices::device_id, devices::sync_group_id)) + .filter(devices::user_id.eq(user.id)) + .order(devices::sync_group_id) + .load_iter::<(String, Option), _>(conn)?; + + let mut cur_group = &mut not_synchronized; + let mut cur_group_id: Option = None; + + while let Some((device_id, group_id)) = devices.next().transpose()? { + if group_id != cur_group_id { + if group_id.is_none() { + cur_group = &mut not_synchronized; + } else { + synchronized.push(Vec::new()); + let index = synchronized.len() - 1; + cur_group = &mut synchronized[index]; + } + + cur_group_id = group_id; + } + + cur_group.push(device_id); + } + + Ok::<_, DbError>((not_synchronized, synchronized)) + })() + .map_err(AuthErr::from) + } +} diff --git a/gpodder_sqlite/src/repository/episode_action.rs b/gpodder_sqlite/src/repository/episode_action.rs new file mode 100644 index 0000000..bf9aff1 --- /dev/null +++ b/gpodder_sqlite/src/repository/episode_action.rs @@ -0,0 +1,176 @@ +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use gpodder::AuthErr; + +use super::SqliteRepository; +use crate::{ + models::{ + device::Device, + episode_action::{ActionType, EpisodeAction, NewEpisodeAction}, + }, + schema::*, + DbError, +}; + +impl From for NewEpisodeAction { + fn from(value: gpodder::EpisodeAction) -> Self { + let (action, started, position, total) = match value.action { + gpodder::EpisodeActionType::New => (ActionType::New, None, None, None), + gpodder::EpisodeActionType::Delete => (ActionType::Delete, None, None, None), + gpodder::EpisodeActionType::Download => (ActionType::Download, None, None, None), + gpodder::EpisodeActionType::Play { + started, + position, + total, + } => (ActionType::Play, started, Some(position), total), + }; + + NewEpisodeAction { + user_id: 0, + device_id: None, + podcast_url: value.podcast, + episode_url: value.episode, + time_changed: 0, + timestamp: value.timestamp.map(|t| t.timestamp()), + action, + started, + position, + total, + } + } +} + +fn to_gpodder_action( + (device_id, db_action): (Option, EpisodeAction), +) -> gpodder::EpisodeAction { + let action = match db_action.action { + ActionType::Play => gpodder::EpisodeActionType::Play { + started: db_action.started, + // SAFETY: the condition that this isn't null if the action type is "play" is + // explicitely enforced by the database using a CHECK constraint. + position: db_action.position.unwrap(), + total: db_action.total, + }, + ActionType::New => gpodder::EpisodeActionType::New, + ActionType::Delete => gpodder::EpisodeActionType::Delete, + ActionType::Download => gpodder::EpisodeActionType::Download, + }; + + gpodder::EpisodeAction { + podcast: db_action.podcast_url, + episode: db_action.episode_url, + timestamp: db_action + .timestamp + // SAFETY the input to the from_timestamp function is always the result of a + // previous timestamp() function call, which is guaranteed to be each other's + // reverse + .map(|ts| DateTime::from_timestamp(ts, 0).unwrap()), + time_changed: DateTime::from_timestamp(db_action.time_changed, 0).unwrap(), + device: device_id, + action, + } +} + +impl gpodder::EpisodeActionRepository for SqliteRepository { + fn add_episode_actions( + &self, + user: &gpodder::User, + actions: Vec, + time_changed: DateTime, + ) -> Result<(), gpodder::AuthErr> { + (|| { + let time_changed = time_changed.timestamp(); + + // TODO optimize this query + // 1. The lookup for a device could be replaced with a subquery, although Diesel seems to + // have a problem using an Option to match equality with a String + // 2. Ideally the for loop would be replaced with a single query inserting multiple values, + // although each value would need its own subquery + // + // NOTE this function usually gets called from the same device, so optimizing the + // amount of device lookups required would be useful. + self.pool.get()?.transaction(|conn| { + for action in actions { + let device_id = if let Some(device) = &action.device { + Some(Device::device_id_to_id(conn, user.id, device)?) + } else { + None + }; + + let mut new_action: NewEpisodeAction = action.into(); + new_action.user_id = user.id; + new_action.device_id = device_id; + new_action.time_changed = time_changed; + + diesel::insert_into(episode_actions::table) + .values(&new_action) + .execute(conn)?; + } + + Ok::<_, DbError>(()) + }) + })() + .map_err(AuthErr::from) + } + + fn episode_actions_for_user( + &self, + user: &gpodder::User, + since: Option>, + podcast: Option, + device: Option, + aggregated: bool, + ) -> Result, gpodder::AuthErr> { + (|| { + let since = since.map(|ts| ts.timestamp()).unwrap_or(0); + let conn = &mut self.pool.get()?; + + let mut query = episode_actions::table + .left_join(devices::table) + .filter( + episode_actions::user_id + .eq(user.id) + .and(episode_actions::time_changed.ge(since)), + ) + .select((devices::device_id.nullable(), EpisodeAction::as_select())) + .into_boxed(); + + if let Some(device_id) = device { + query = query.filter(devices::device_id.eq(device_id)); + } + + if let Some(podcast_url) = podcast { + query = query.filter(episode_actions::podcast_url.eq(podcast_url)); + } + + let db_actions: Vec<(Option, EpisodeAction)> = if aggregated { + // https://stackoverflow.com/a/7745635 + // For each episode URL, we want to return the row with the highest `time_changed` + // value. We achieve this be left joining with self on the URL, as well as whether the + // left row's time_changed value is less than the right one. Rows with the largest + // time_changed value for a given URL will join with a NULL value (because of the left + // join), so we filter those out to retrieve the correct rows. + let a2 = diesel::alias!(episode_actions as a2); + + query + .left_join( + a2.on(episode_actions::episode_url + .eq(a2.field(episode_actions::episode_url)) + .and( + episode_actions::time_changed + .lt(a2.field(episode_actions::time_changed)), + )), + ) + .filter(a2.field(episode_actions::episode_url).is_null()) + .get_results(conn)? + } else { + query.get_results(conn)? + }; + + let actions = db_actions.into_iter().map(to_gpodder_action).collect(); + + Ok::<_, DbError>(actions) + })() + .map_err(AuthErr::from) + } +} diff --git a/src/db/repository/mod.rs b/gpodder_sqlite/src/repository/mod.rs similarity index 54% rename from src/db/repository/mod.rs rename to gpodder_sqlite/src/repository/mod.rs index 01a81d4..6a80b28 100644 --- a/src/db/repository/mod.rs +++ b/gpodder_sqlite/src/repository/mod.rs @@ -3,6 +3,8 @@ mod device; mod episode_action; mod subscription; +use std::path::Path; + use super::DbPool; #[derive(Clone)] @@ -15,3 +17,11 @@ impl From for SqliteRepository { Self { pool: value } } } + +impl SqliteRepository { + pub fn from_path(path: impl AsRef) -> Result { + let pool = super::initialize_db(path, true)?; + + Ok(Self { pool }) + } +} diff --git a/src/db/repository/subscription.rs b/gpodder_sqlite/src/repository/subscription.rs similarity index 55% rename from src/db/repository/subscription.rs rename to gpodder_sqlite/src/repository/subscription.rs index 447a27d..da407a1 100644 --- a/src/db/repository/subscription.rs +++ b/gpodder_sqlite/src/repository/subscription.rs @@ -2,22 +2,15 @@ use std::collections::HashSet; use chrono::DateTime; use diesel::prelude::*; +use gpodder::AuthErr; use super::SqliteRepository; use crate::{ - db::{self, schema::*}, - gpodder, + models::device_subscription::{DeviceSubscription, NewDeviceSubscription}, + schema::*, + DbError, }; -impl From<(String, i64)> for gpodder::Subscription { - fn from((url, ts): (String, i64)) -> Self { - Self { - url, - time_changed: DateTime::from_timestamp(ts, 0).unwrap(), - } - } -} - fn set_subscriptions_for_single_device( conn: &mut SqliteConnection, device_id: i64, @@ -80,7 +73,7 @@ fn set_subscriptions_for_single_device( .values( urls_to_insert .into_iter() - .map(|url| db::NewDeviceSubscription { + .map(|url| NewDeviceSubscription { device_id, podcast_url: url.to_string(), deleted: false, @@ -105,7 +98,7 @@ pub fn insert_subscriptions_for_single_device<'a>( diesel::insert_into(device_subscriptions::table) .values( urls.into_iter() - .map(|url| db::NewDeviceSubscription { + .map(|url| NewDeviceSubscription { device_id, podcast_url: url.to_string(), deleted: false, @@ -184,18 +177,26 @@ impl gpodder::SubscriptionRepository for SqliteRepository { &self, user: &gpodder::User, ) -> Result, gpodder::AuthErr> { - Ok(device_subscriptions::table - .inner_join(devices::table) - .filter(devices::user_id.eq(user.id)) - .select(( - device_subscriptions::podcast_url, - device_subscriptions::time_changed, - )) - .distinct() - .get_results::<(String, i64)>(&mut self.pool.get()?)? - .into_iter() - .map(Into::into) - .collect()) + (|| { + Ok::<_, DbError>( + device_subscriptions::table + .inner_join(devices::table) + .filter(devices::user_id.eq(user.id)) + .select(( + device_subscriptions::podcast_url, + device_subscriptions::time_changed, + )) + .distinct() + .get_results::<(String, i64)>(&mut self.pool.get()?)? + .into_iter() + .map(|(url, ts)| gpodder::Subscription { + url, + time_changed: DateTime::from_timestamp(ts, 0).unwrap(), + }) + .collect(), + ) + })() + .map_err(AuthErr::from) } fn subscriptions_for_device( @@ -203,21 +204,29 @@ impl gpodder::SubscriptionRepository for SqliteRepository { user: &gpodder::User, device_id: &str, ) -> Result, gpodder::AuthErr> { - Ok(device_subscriptions::table - .inner_join(devices::table) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq(device_id)), + (|| { + Ok::<_, DbError>( + device_subscriptions::table + .inner_join(devices::table) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq(device_id)), + ) + .select(( + device_subscriptions::podcast_url, + device_subscriptions::time_changed, + )) + .get_results::<(String, i64)>(&mut self.pool.get()?)? + .into_iter() + .map(|(url, ts)| gpodder::Subscription { + url, + time_changed: DateTime::from_timestamp(ts, 0).unwrap(), + }) + .collect(), ) - .select(( - device_subscriptions::podcast_url, - device_subscriptions::time_changed, - )) - .get_results::<(String, i64)>(&mut self.pool.get()?)? - .into_iter() - .map(Into::into) - .collect()) + })() + .map_err(AuthErr::from) } fn set_subscriptions_for_device( @@ -227,38 +236,39 @@ impl gpodder::SubscriptionRepository for SqliteRepository { urls: Vec, time_changed: chrono::DateTime, ) -> Result<(), gpodder::AuthErr> { - let time_changed = time_changed.timestamp(); - let urls: HashSet = urls.into_iter().collect(); + (|| { + let time_changed = time_changed.timestamp(); + let urls: HashSet = urls.into_iter().collect(); - self.pool.get()?.transaction(|conn| { - let (device_id, group_id) = devices::table - .select((devices::id, devices::sync_group_id)) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq(device_id)), - ) - .get_result::<(i64, Option)>(conn)?; + self.pool.get()?.transaction(|conn| { + let (device_id, group_id) = devices::table + .select((devices::id, devices::sync_group_id)) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq(device_id)), + ) + .get_result::<(i64, Option)>(conn)?; - // If the device is part of a sync group, we need to perform the update on every device - // in the group - if let Some(group_id) = group_id { - let device_ids: Vec = devices::table - .filter(devices::sync_group_id.eq(group_id)) - .select(devices::id) - .get_results(conn)?; + // If the device is part of a sync group, we need to perform the update on every device + // in the group + if let Some(group_id) = group_id { + let device_ids: Vec = devices::table + .filter(devices::sync_group_id.eq(group_id)) + .select(devices::id) + .get_results(conn)?; - for device_id in device_ids { + for device_id in device_ids { + set_subscriptions_for_single_device(conn, device_id, &urls, time_changed)?; + } + } else { set_subscriptions_for_single_device(conn, device_id, &urls, time_changed)?; } - } else { - set_subscriptions_for_single_device(conn, device_id, &urls, time_changed)?; - } - Ok::<_, diesel::result::Error>(()) - })?; - - Ok(()) + Ok::<_, DbError>(()) + }) + })() + .map_err(AuthErr::from) } fn update_subscriptions_for_device( @@ -269,32 +279,42 @@ impl gpodder::SubscriptionRepository for SqliteRepository { remove: Vec, time_changed: chrono::DateTime, ) -> Result<(), gpodder::AuthErr> { - let time_changed = time_changed.timestamp(); + (|| { + let time_changed = time_changed.timestamp(); - // TODO URLs that are in both the added and removed lists will currently get "re-added", - // meaning their change timestamp will be updated even though they haven't really changed. - let add: HashSet<_> = add.into_iter().collect(); - let remove: HashSet<_> = remove.into_iter().collect(); + // TODO URLs that are in both the added and removed lists will currently get "re-added", + // meaning their change timestamp will be updated even though they haven't really changed. + let add: HashSet<_> = add.into_iter().collect(); + let remove: HashSet<_> = remove.into_iter().collect(); - self.pool.get()?.transaction(|conn| { - let (device_id, group_id) = devices::table - .select((devices::id, devices::sync_group_id)) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq(device_id)), - ) - .get_result::<(i64, Option)>(conn)?; + self.pool.get()?.transaction(|conn| { + let (device_id, group_id) = devices::table + .select((devices::id, devices::sync_group_id)) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq(device_id)), + ) + .get_result::<(i64, Option)>(conn)?; - // If the device is part of a sync group, we need to perform the update on every device - // in the group - if let Some(group_id) = group_id { - let device_ids: Vec = devices::table - .filter(devices::sync_group_id.eq(group_id)) - .select(devices::id) - .get_results(conn)?; + // If the device is part of a sync group, we need to perform the update on every device + // in the group + if let Some(group_id) = group_id { + let device_ids: Vec = devices::table + .filter(devices::sync_group_id.eq(group_id)) + .select(devices::id) + .get_results(conn)?; - for device_id in device_ids { + for device_id in device_ids { + update_subscriptions_for_single_device( + conn, + device_id, + &add, + &remove, + time_changed, + )?; + } + } else { update_subscriptions_for_single_device( conn, device_id, @@ -303,20 +323,11 @@ impl gpodder::SubscriptionRepository for SqliteRepository { time_changed, )?; } - } else { - update_subscriptions_for_single_device( - conn, - device_id, - &add, - &remove, - time_changed, - )?; - } - Ok::<_, diesel::result::Error>(()) - })?; - - Ok(()) + Ok::<_, DbError>(()) + }) + })() + .map_err(AuthErr::from) } fn subscription_updates_for_device( @@ -325,36 +336,39 @@ impl gpodder::SubscriptionRepository for SqliteRepository { device_id: &str, since: chrono::DateTime, ) -> Result<(Vec, Vec), gpodder::AuthErr> { - let since = since.timestamp(); + (|| { + let since = since.timestamp(); - let (mut added, mut removed) = (Vec::new(), Vec::new()); + let (mut added, mut removed) = (Vec::new(), Vec::new()); - let query = device_subscriptions::table - .inner_join(devices::table) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq(device_id)) - .and(device_subscriptions::time_changed.ge(since)), - ) - .select(db::DeviceSubscription::as_select()); + let query = device_subscriptions::table + .inner_join(devices::table) + .filter( + devices::user_id + .eq(user.id) + .and(devices::device_id.eq(device_id)) + .and(device_subscriptions::time_changed.ge(since)), + ) + .select(DeviceSubscription::as_select()); - for sub in query.load_iter(&mut self.pool.get()?)? { - let sub = sub?; + for sub in query.load_iter(&mut self.pool.get()?)? { + let sub = sub?; - if sub.deleted { - removed.push(gpodder::Subscription { - url: sub.podcast_url, - time_changed: DateTime::from_timestamp(sub.time_changed, 0).unwrap(), - }); - } else { - added.push(gpodder::Subscription { - url: sub.podcast_url, - time_changed: DateTime::from_timestamp(sub.time_changed, 0).unwrap(), - }); + if sub.deleted { + removed.push(gpodder::Subscription { + url: sub.podcast_url, + time_changed: DateTime::from_timestamp(sub.time_changed, 0).unwrap(), + }); + } else { + added.push(gpodder::Subscription { + url: sub.podcast_url, + time_changed: DateTime::from_timestamp(sub.time_changed, 0).unwrap(), + }); + } } - } - Ok((added, removed)) + Ok::<_, DbError>((added, removed)) + })() + .map_err(AuthErr::from) } } diff --git a/src/db/schema.rs b/gpodder_sqlite/src/schema.rs similarity index 100% rename from src/db/schema.rs rename to gpodder_sqlite/src/schema.rs diff --git a/src/cli/gpo.rs b/src/cli/gpo.rs index cbf5dbd..e4d951b 100644 --- a/src/cli/gpo.rs +++ b/src/cli/gpo.rs @@ -1,7 +1,5 @@ use clap::Subcommand; -use crate::db; - #[derive(Subcommand)] pub enum Command { /// Add devices of a specific user to the same sync group @@ -15,9 +13,10 @@ pub enum Command { impl Command { pub fn run(&self, config: &crate::config::Config) -> u8 { - let pool = db::initialize_db(config.data_dir.join(crate::DB_FILENAME), true).unwrap(); - let repo = db::SqliteRepository::from(pool); - let store = crate::gpodder::GpodderRepository::new(repo); + let store = + gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME)) + .unwrap(); + let store = gpodder::GpodderRepository::new(store); match self { Self::Sync { username, devices } => { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index dc0a8ba..512c955 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ -mod db; +// mod db; mod gpo; mod serve; @@ -48,9 +48,8 @@ pub struct ClapConfig { #[derive(Subcommand)] pub enum Command { Serve, - #[command(subcommand)] - Db(db::DbCommand), - + // #[command(subcommand)] + // Db(db::DbCommand), /// Perform operations on the database through the Gpodder abstraction, allowing operations /// identical to the ones performed by the API. #[command(subcommand)] @@ -80,7 +79,7 @@ impl Cli { match &self.cmd { Command::Serve => serve::serve(&config), - Command::Db(cmd) => cmd.run(&config), + // Command::Db(cmd) => cmd.run(&config), Command::Gpo(cmd) => cmd.run(&config), } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 67cf6e7..a434db0 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,18 +1,18 @@ use std::time::Duration; -use crate::{db, server}; +use crate::server; pub fn serve(config: &crate::config::Config) -> u8 { tracing_subscriber::fmt::init(); tracing::info!("Initializing database and running migrations"); - let pool = db::initialize_db(config.data_dir.join(crate::DB_FILENAME), true).unwrap(); - let repo = db::SqliteRepository::from(pool); + let store = + gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME)) + .unwrap(); + let store = gpodder::GpodderRepository::new(store); - let ctx = server::Context { - store: crate::gpodder::GpodderRepository::new(repo), - }; + let ctx = server::Context { store }; let app = server::app(ctx.clone()); let rt = tokio::runtime::Builder::new_multi_thread() diff --git a/src/db/models/session.rs b/src/db/models/session.rs deleted file mode 100644 index 273550a..0000000 --- a/src/db/models/session.rs +++ /dev/null @@ -1,62 +0,0 @@ -use diesel::prelude::*; -use rand::Rng; - -use super::user::User; -use crate::db::{schema::*, DbPool, DbResult}; - -#[derive(Clone, Queryable, Selectable, Insertable, Associations)] -#[diesel(belongs_to(super::user::User))] -#[diesel(table_name = sessions)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct Session { - pub id: i64, - pub user_id: i64, - pub last_seen: i64, -} - -impl Session { - pub fn new_for_user(pool: &DbPool, user_id: i64, last_seen: i64) -> DbResult { - let id: i64 = rand::thread_rng().gen(); - - Ok(Self { - id, - user_id, - last_seen, - } - .insert_into(sessions::table) - .returning(Self::as_returning()) - .get_result(&mut pool.get()?)?) - } - - pub fn user_from_id(pool: &DbPool, id: i64) -> DbResult> { - Ok(sessions::dsl::sessions - .inner_join(users::table) - .filter(sessions::id.eq(id)) - .select(User::as_select()) - .get_result(&mut pool.get()?) - .optional()?) - } - - pub fn user(&self, pool: &DbPool) -> DbResult> { - Self::user_from_id(pool, self.id) - } - - pub fn by_id(pool: &DbPool, id: i64) -> DbResult> { - Ok(sessions::dsl::sessions - .find(id) - .get_result(&mut pool.get()?) - .optional()?) - } - - pub fn remove(self, pool: &DbPool) -> DbResult { - Self::remove_by_id(pool, self.id) - } - - pub fn remove_by_id(pool: &DbPool, id: i64) -> DbResult { - Ok( - diesel::delete(sessions::dsl::sessions.filter(sessions::id.eq(id))) - .execute(&mut pool.get()?)? - > 0, - ) - } -} diff --git a/src/db/models/user.rs b/src/db/models/user.rs deleted file mode 100644 index f9c9bb9..0000000 --- a/src/db/models/user.rs +++ /dev/null @@ -1,67 +0,0 @@ -use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use diesel::prelude::*; -use rand::rngs::OsRng; -use serde::{Deserialize, Serialize}; - -use crate::db::{schema::*, DbPool, DbResult}; - -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct User { - pub id: i64, - pub username: String, - pub password_hash: String, -} - -#[derive(Deserialize, Insertable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct NewUser { - pub username: String, - pub password_hash: String, -} - -fn hash_password(password: impl AsRef) -> String { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - - argon2 - .hash_password(password.as_ref().as_bytes(), &salt) - .unwrap() - .to_string() -} - -impl NewUser { - pub fn new(username: String, password: String) -> Self { - Self { - username, - password_hash: hash_password(&password), - } - } - - pub fn insert(self, pool: &DbPool) -> DbResult { - Ok(diesel::insert_into(users::table) - .values(self) - .returning(User::as_returning()) - .get_result(&mut pool.get()?)?) - } -} - -impl User { - pub fn by_username(pool: &DbPool, username: impl AsRef) -> DbResult> { - Ok(users::dsl::users - .select(User::as_select()) - .filter(users::username.eq(username.as_ref())) - .first(&mut pool.get()?) - .optional()?) - } - - pub fn verify_password(&self, password: impl AsRef) -> bool { - let password_hash = PasswordHash::new(&self.password_hash).unwrap(); - - Argon2::default() - .verify_password(password.as_ref().as_bytes(), &password_hash) - .is_ok() - } -} diff --git a/src/db/repository/device.rs b/src/db/repository/device.rs deleted file mode 100644 index cd1a4d9..0000000 --- a/src/db/repository/device.rs +++ /dev/null @@ -1,275 +0,0 @@ -use std::collections::HashSet; - -use chrono::{DateTime, Utc}; -use diesel::{alias, dsl::not, prelude::*}; - -use super::SqliteRepository; -use crate::{ - db::{self, schema::*, SyncGroup}, - gpodder, -}; - -impl From for gpodder::DeviceType { - fn from(value: db::DeviceType) -> Self { - match value { - db::DeviceType::Desktop => Self::Desktop, - db::DeviceType::Laptop => Self::Laptop, - db::DeviceType::Mobile => Self::Mobile, - db::DeviceType::Server => Self::Server, - db::DeviceType::Other => Self::Other, - } - } -} - -impl From for db::DeviceType { - fn from(value: gpodder::DeviceType) -> Self { - match value { - gpodder::DeviceType::Desktop => Self::Desktop, - gpodder::DeviceType::Laptop => Self::Laptop, - gpodder::DeviceType::Mobile => Self::Mobile, - gpodder::DeviceType::Server => Self::Server, - gpodder::DeviceType::Other => Self::Other, - } - } -} - -impl gpodder::DeviceRepository for SqliteRepository { - fn devices_for_user( - &self, - user: &gpodder::User, - ) -> Result, gpodder::AuthErr> { - Ok(devices::table - .select(db::Device::as_select()) - .filter(devices::user_id.eq(user.id)) - .get_results(&mut self.pool.get()?)? - .into_iter() - .map(|d| gpodder::Device { - id: d.device_id, - caption: d.caption, - r#type: d.type_.into(), - // TODO implement subscription count - subscriptions: 0, - }) - .collect()) - } - - fn update_device_info( - &self, - user: &gpodder::User, - device_id: &str, - patch: gpodder::DevicePatch, - ) -> Result<(), gpodder::AuthErr> { - if let Some(mut device) = devices::table - .select(db::Device::as_select()) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq(device_id)), - ) - .get_result(&mut self.pool.get()?) - .optional()? - { - if let Some(caption) = patch.caption { - device.caption = caption; - } - - if let Some(type_) = patch.r#type { - device.type_ = type_.into(); - } - - diesel::update(devices::table.filter(devices::id.eq(device.id))) - .set(( - devices::caption.eq(&device.caption), - devices::type_.eq(&device.type_), - )) - .execute(&mut self.pool.get()?)?; - } else { - let device = db::NewDevice { - device_id: device_id.to_string(), - user_id: user.id, - caption: patch.caption.unwrap_or(String::new()), - type_: patch.r#type.unwrap_or(gpodder::DeviceType::Other).into(), - }; - - diesel::insert_into(devices::table) - .values(device) - .execute(&mut self.pool.get()?)?; - } - - Ok(()) - } - - fn merge_sync_groups( - &self, - user: &gpodder::User, - device_ids: Vec<&str>, - ) -> Result { - let conn = &mut self.pool.get()?; - - Ok(conn.transaction(|conn| { - let devices: Vec<(i64, Option)> = devices::table - .select((devices::id, devices::sync_group_id)) - .filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq_any(device_ids)), - ) - .get_results(conn)?; - - let mut sync_group_ids: Vec = devices - .iter() - .filter_map(|(_, group_id)| *group_id) - .collect(); - - // Remove any duplicates, giving us each sync group ID once - sync_group_ids.sort(); - sync_group_ids.dedup(); - - // If any of the devices are already in a sync group, we reuse the first one we find. - // Otherwise, we generate a new one. - let sync_group_id = if let Some(id) = sync_group_ids.pop() { - id - } else { - db::SyncGroup::new(conn)?.id - }; - - // Move all devices in the other sync groups into the new sync group - diesel::update( - devices::table.filter(devices::sync_group_id.eq_any(sync_group_ids.iter())), - ) - .set(devices::sync_group_id.eq(sync_group_id)) - .execute(conn)?; - - // Add the non-synchronized devices into the new sync group - let unsynced_device_ids = - devices - .iter() - .filter_map(|(id, group_id)| if group_id.is_none() { Some(id) } else { None }); - - diesel::update(devices::table.filter(devices::id.eq_any(unsynced_device_ids))) - .set(devices::sync_group_id.eq(sync_group_id)) - .execute(conn)?; - - // Remove the other now unused sync groups - diesel::delete(sync_groups::table.filter(sync_groups::id.eq_any(sync_group_ids))) - .execute(conn)?; - - Ok::<_, diesel::result::Error>(sync_group_id) - })?) - } - - fn remove_from_sync_group( - &self, - user: &gpodder::User, - device_ids: Vec<&str>, - ) -> Result<(), gpodder::AuthErr> { - let conn = &mut self.pool.get()?; - - diesel::update( - devices::table.filter( - devices::user_id - .eq(user.id) - .and(devices::device_id.eq_any(device_ids)), - ), - ) - .set(devices::sync_group_id.eq(None::)) - .execute(conn)?; - - // This is in a different transaction on purpose, as the success of this removal shouldn't - // fail the entire query - SyncGroup::remove_unused(conn)?; - - Ok(()) - } - - fn synchronize_sync_group( - &self, - group_id: i64, - time_changed: DateTime, - ) -> Result<(), gpodder::AuthErr> { - let time_changed = time_changed.timestamp(); - let conn = &mut self.pool.get()?; - - conn.transaction(|conn| { - let device_ids: Vec = devices::table - .filter(devices::sync_group_id.eq(group_id)) - .select(devices::id) - .get_results(conn)?; - - // For each device in the group, we get the list of subscriptions not yet in its own - // non-deleted list, and add it to the database - for device_id in device_ids.iter().copied() { - let d1 = alias!(device_subscriptions as d1); - - let own_subscriptions = d1 - .filter( - d1.field(device_subscriptions::device_id) - .eq(device_id) - .and(d1.field(device_subscriptions::deleted).eq(false)), - ) - .select(d1.field(device_subscriptions::podcast_url)); - - let urls_to_add = device_subscriptions::table - .select(device_subscriptions::podcast_url) - .filter( - device_subscriptions::device_id - .eq_any(device_ids.iter()) - .and(device_subscriptions::deleted.eq(false)) - .and(not( - device_subscriptions::podcast_url.eq_any(own_subscriptions) - )), - ) - .distinct() - .load_iter(conn)? - .collect::, _>>()?; - - super::subscription::insert_subscriptions_for_single_device( - conn, - device_id, - urls_to_add.iter(), - time_changed, - )?; - } - - Ok::<_, diesel::result::Error>(()) - })?; - - Ok(()) - } - - fn devices_by_sync_group( - &self, - user: &gpodder::User, - ) -> Result<(Vec, Vec>), gpodder::AuthErr> { - let mut not_synchronized = Vec::new(); - let mut synchronized = Vec::new(); - - let conn = &mut self.pool.get()?; - let mut devices = devices::table - .select((devices::device_id, devices::sync_group_id)) - .filter(devices::user_id.eq(user.id)) - .order(devices::sync_group_id) - .load_iter::<(String, Option), _>(conn)?; - - let mut cur_group = &mut not_synchronized; - let mut cur_group_id: Option = None; - - while let Some((device_id, group_id)) = devices.next().transpose()? { - if group_id != cur_group_id { - if group_id.is_none() { - cur_group = &mut not_synchronized; - } else { - synchronized.push(Vec::new()); - let index = synchronized.len() - 1; - cur_group = &mut synchronized[index]; - } - - cur_group_id = group_id; - } - - cur_group.push(device_id); - } - - Ok((not_synchronized, synchronized)) - } -} diff --git a/src/db/repository/episode_action.rs b/src/db/repository/episode_action.rs deleted file mode 100644 index 5a45cd2..0000000 --- a/src/db/repository/episode_action.rs +++ /dev/null @@ -1,170 +0,0 @@ -use chrono::{DateTime, Utc}; -use diesel::prelude::*; - -use super::SqliteRepository; -use crate::{ - db::{self, schema::*}, - gpodder, -}; - -impl From for db::NewEpisodeAction { - fn from(value: gpodder::EpisodeAction) -> Self { - let (action, started, position, total) = match value.action { - gpodder::EpisodeActionType::New => (db::ActionType::New, None, None, None), - gpodder::EpisodeActionType::Delete => (db::ActionType::Delete, None, None, None), - gpodder::EpisodeActionType::Download => (db::ActionType::Download, None, None, None), - gpodder::EpisodeActionType::Play { - started, - position, - total, - } => (db::ActionType::Play, started, Some(position), total), - }; - - db::NewEpisodeAction { - user_id: 0, - device_id: None, - podcast_url: value.podcast, - episode_url: value.episode, - time_changed: 0, - timestamp: value.timestamp.map(|t| t.timestamp()), - action, - started, - position, - total, - } - } -} - -impl From<(Option, db::EpisodeAction)> for gpodder::EpisodeAction { - fn from((device_id, db_action): (Option, db::EpisodeAction)) -> Self { - let action = match db_action.action { - db::ActionType::Play => gpodder::EpisodeActionType::Play { - started: db_action.started, - // SAFETY: the condition that this isn't null if the action type is "play" is - // explicitely enforced by the database using a CHECK constraint. - position: db_action.position.unwrap(), - total: db_action.total, - }, - db::ActionType::New => gpodder::EpisodeActionType::New, - db::ActionType::Delete => gpodder::EpisodeActionType::Delete, - db::ActionType::Download => gpodder::EpisodeActionType::Download, - }; - - Self { - podcast: db_action.podcast_url, - episode: db_action.episode_url, - timestamp: db_action - .timestamp - // SAFETY the input to the from_timestamp function is always the result of a - // previous timestamp() function call, which is guaranteed to be each other's - // reverse - .map(|ts| DateTime::from_timestamp(ts, 0).unwrap()), - time_changed: DateTime::from_timestamp(db_action.time_changed, 0).unwrap(), - device: device_id, - action, - } - } -} - -impl gpodder::EpisodeActionRepository for SqliteRepository { - fn add_episode_actions( - &self, - user: &gpodder::User, - actions: Vec, - time_changed: DateTime, - ) -> Result<(), gpodder::AuthErr> { - let time_changed = time_changed.timestamp(); - - // TODO optimize this query - // 1. The lookup for a device could be replaced with a subquery, although Diesel seems to - // have a problem using an Option to match equality with a String - // 2. Ideally the for loop would be replaced with a single query inserting multiple values, - // although each value would need its own subquery - self.pool.get()?.transaction(|conn| { - for action in actions { - let device_id = if let Some(device) = &action.device { - Some(db::Device::device_id_to_id(conn, user.id, device)?) - } else { - None - }; - - let mut new_action: db::NewEpisodeAction = action.into(); - new_action.user_id = user.id; - new_action.device_id = device_id; - new_action.time_changed = time_changed; - - diesel::insert_into(episode_actions::table) - .values(&new_action) - .execute(conn)?; - } - - Ok::<_, diesel::result::Error>(()) - })?; - - Ok(()) - } - - fn episode_actions_for_user( - &self, - user: &gpodder::User, - since: Option>, - podcast: Option, - device: Option, - aggregated: bool, - ) -> Result, gpodder::AuthErr> { - let since = since.map(|ts| ts.timestamp()).unwrap_or(0); - let conn = &mut self.pool.get()?; - - let mut query = episode_actions::table - .left_join(devices::table) - .filter( - episode_actions::user_id - .eq(user.id) - .and(episode_actions::time_changed.ge(since)), - ) - .select(( - devices::device_id.nullable(), - db::EpisodeAction::as_select(), - )) - .into_boxed(); - - if let Some(device_id) = device { - query = query.filter(devices::device_id.eq(device_id)); - } - - if let Some(podcast_url) = podcast { - query = query.filter(episode_actions::podcast_url.eq(podcast_url)); - } - - let db_actions: Vec<(Option, db::EpisodeAction)> = if aggregated { - // https://stackoverflow.com/a/7745635 - // For each episode URL, we want to return the row with the highest `time_changed` - // value. We achieve this be left joining with self on the URL, as well as whether the - // left row's time_changed value is less than the right one. Rows with the largest - // time_changed value for a given URL will join with a NULL value (because of the left - // join), so we filter those out to retrieve the correct rows. - let a2 = diesel::alias!(episode_actions as a2); - - query - .left_join( - a2.on(episode_actions::episode_url - .eq(a2.field(episode_actions::episode_url)) - .and( - episode_actions::time_changed - .lt(a2.field(episode_actions::time_changed)), - )), - ) - .filter(a2.field(episode_actions::episode_url).is_null()) - .get_results(conn)? - } else { - query.get_results(conn)? - }; - - let actions = db_actions - .into_iter() - .map(gpodder::EpisodeAction::from) - .collect(); - - Ok(actions) - } -} diff --git a/src/main.rs b/src/main.rs index 2e17a7b..b42d31a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,5 @@ mod cli; mod config; -mod db; -mod gpodder; mod server; use clap::Parser; diff --git a/src/server/error.rs b/src/server/error.rs index 5d7aeb4..dacc052 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -2,13 +2,13 @@ use std::fmt; use axum::{http::StatusCode, response::IntoResponse}; -use crate::{db, ErrorExt}; +use crate::ErrorExt; pub type AppResult = Result; #[derive(Debug)] pub enum AppError { - Db(db::DbError), + // Db(db::DbError), IO(std::io::Error), Other(Box), BadRequest, @@ -19,7 +19,7 @@ pub enum AppError { impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Db(_) => write!(f, "database error"), + // Self::Db(_) => write!(f, "database error"), Self::IO(_) => write!(f, "io error"), Self::Other(_) => write!(f, "other error"), Self::BadRequest => write!(f, "bad request"), @@ -32,7 +32,7 @@ impl fmt::Display for AppError { impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Db(err) => Some(err), + // Self::Db(err) => Some(err), Self::IO(err) => Some(err), Self::Other(err) => Some(err.as_ref()), Self::NotFound | Self::Unauthorized | Self::BadRequest => None, @@ -40,12 +40,6 @@ impl std::error::Error for AppError { } } -impl From for AppError { - fn from(value: db::DbError) -> Self { - Self::Db(value) - } -} - impl From for AppError { fn from(value: std::io::Error) -> Self { Self::IO(value) diff --git a/src/server/gpodder/advanced/auth.rs b/src/server/gpodder/advanced/auth.rs index b0bccaf..b4adaeb 100644 --- a/src/server/gpodder/advanced/auth.rs +++ b/src/server/gpodder/advanced/auth.rs @@ -10,13 +10,10 @@ use axum_extra::{ }; use cookie::time::Duration; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::SESSION_ID_COOKIE, - Context, - }, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::SESSION_ID_COOKIE, + Context, }; pub fn router() -> Router { diff --git a/src/server/gpodder/advanced/devices.rs b/src/server/gpodder/advanced/devices.rs index 859e1c1..67f4f79 100644 --- a/src/server/gpodder/advanced/devices.rs +++ b/src/server/gpodder/advanced/devices.rs @@ -5,17 +5,14 @@ use axum::{ Extension, Json, Router, }; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::{ - auth_middleware, - format::{Format, StringWithFormat}, - models, - }, - Context, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::{ + auth_middleware, + format::{Format, StringWithFormat}, + models, }, + Context, }; pub fn router(ctx: Context) -> Router { diff --git a/src/server/gpodder/advanced/episodes.rs b/src/server/gpodder/advanced/episodes.rs index 6c7ff32..5ea62d6 100644 --- a/src/server/gpodder/advanced/episodes.rs +++ b/src/server/gpodder/advanced/episodes.rs @@ -7,18 +7,15 @@ use axum::{ use chrono::DateTime; use serde::{Deserialize, Serialize}; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::{ - auth_middleware, - format::{Format, StringWithFormat}, - models, - models::UpdatedUrlsResponse, - }, - Context, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::{ + auth_middleware, + format::{Format, StringWithFormat}, + models, + models::UpdatedUrlsResponse, }, + Context, }; pub fn router(ctx: Context) -> Router { diff --git a/src/server/gpodder/advanced/subscriptions.rs b/src/server/gpodder/advanced/subscriptions.rs index e5691e1..b69f420 100644 --- a/src/server/gpodder/advanced/subscriptions.rs +++ b/src/server/gpodder/advanced/subscriptions.rs @@ -6,17 +6,14 @@ use axum::{ }; use serde::Deserialize; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::{ - auth_middleware, - format::{Format, StringWithFormat}, - models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse}, - }, - Context, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::{ + auth_middleware, + format::{Format, StringWithFormat}, + models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse}, }, + Context, }; pub fn router(ctx: Context) -> Router { diff --git a/src/server/gpodder/advanced/sync.rs b/src/server/gpodder/advanced/sync.rs index fc97869..728452c 100644 --- a/src/server/gpodder/advanced/sync.rs +++ b/src/server/gpodder/advanced/sync.rs @@ -5,17 +5,14 @@ use axum::{ Extension, Json, Router, }; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::{ - auth_middleware, - format::{Format, StringWithFormat}, - models::{SyncStatus, SyncStatusDelta}, - }, - Context, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::{ + auth_middleware, + format::{Format, StringWithFormat}, + models::{SyncStatus, SyncStatusDelta}, }, + Context, }; pub fn router(ctx: Context) -> Router { diff --git a/src/server/gpodder/mod.rs b/src/server/gpodder/mod.rs index 4746323..83442f3 100644 --- a/src/server/gpodder/mod.rs +++ b/src/server/gpodder/mod.rs @@ -17,7 +17,7 @@ use axum_extra::{ }; use tower_http::set_header::SetResponseHeaderLayer; -use crate::{gpodder, server::error::AppError}; +use crate::server::error::AppError; use super::Context; diff --git a/src/server/gpodder/models.rs b/src/server/gpodder/models.rs index 20d459f..5e0f010 100644 --- a/src/server/gpodder/models.rs +++ b/src/server/gpodder/models.rs @@ -1,8 +1,6 @@ use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::gpodder; - #[derive(Deserialize, Debug)] pub struct SubscriptionDelta { pub add: Vec, diff --git a/src/server/gpodder/simple/subscriptions.rs b/src/server/gpodder/simple/subscriptions.rs index 6e9227c..a91b766 100644 --- a/src/server/gpodder/simple/subscriptions.rs +++ b/src/server/gpodder/simple/subscriptions.rs @@ -5,13 +5,10 @@ use axum::{ Extension, Json, Router, }; -use crate::{ - gpodder, - server::{ - error::{AppError, AppResult}, - gpodder::{auth_middleware, format::StringWithFormat}, - Context, - }, +use crate::server::{ + error::{AppError, AppResult}, + gpodder::{auth_middleware, format::StringWithFormat}, + Context, }; pub fn router(ctx: Context) -> Router { diff --git a/src/server/mod.rs b/src/server/mod.rs index 93ac8d3..6223e4d 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,18 +1,27 @@ mod error; mod gpodder; -use axum::{extract::Request, middleware::Next, response::Response, Router}; +use axum::{ + body::Body, + extract::Request, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, + Router, +}; +use http_body_util::BodyExt; use tower_http::trace::TraceLayer; #[derive(Clone)] pub struct Context { - pub store: crate::gpodder::GpodderRepository, + pub store: ::gpodder::GpodderRepository, } pub fn app(ctx: Context) -> Router { Router::new() .merge(gpodder::router(ctx.clone())) .layer(axum::middleware::from_fn(header_logger)) + .layer(axum::middleware::from_fn(body_logger)) .layer(TraceLayer::new_for_http()) .with_state(ctx) } @@ -26,3 +35,41 @@ async fn header_logger(request: Request, next: Next) -> Response { res } + +async fn body_logger(request: Request, next: Next) -> Response { + let (parts, body) = request.into_parts(); + + let bytes = match body + .collect() + .await + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()) + { + Ok(res) => res.to_bytes(), + Err(err) => { + return err; + } + }; + + tracing::debug!("request body = {:?}", String::from_utf8(bytes.to_vec())); + + let res = next + .run(Request::from_parts(parts, Body::from(bytes))) + .await; + + let (parts, body) = res.into_parts(); + + let bytes = match body + .collect() + .await + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()) + { + Ok(res) => res.to_bytes(), + Err(err) => { + return err; + } + }; + + tracing::debug!("response body = {:?}", String::from_utf8(bytes.to_vec())); + + Response::from_parts(parts, Body::from(bytes)) +}