diff --git a/.dockerignore b/.dockerignore index 0c102f2..59dfed4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ # Except: !/assets +!/migrations !/src !/style !/Cargo.lock diff --git a/.gitignore b/.gitignore index 8a41ec5..ab27e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ playwright/.cache/ # Environment variables .env + +# Sass cache +.sass-cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b2551cc..c74cbba 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ # Build the project build: needs: [] - image: registry.mregirouard.com/libretunes/ops/docker-leptos:latest + image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest script: - cargo-leptos build @@ -12,21 +12,28 @@ docker-build: script: - /usr/local/bin/dockerd-entrypoint.sh & - while ! docker info; do echo "Waiting for Docker to become available..."; sleep 1; done - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.mregirouard.com - - docker build -t registry.mregirouard.com/libretunes/libretunes:$CI_COMMIT_SHORT_SHA . + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . # If running on the default branch, tag as latest - if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag - registry.mregirouard.com/libretunes/libretunes:$CI_COMMIT_SHORT_SHA - registry.mregirouard.com/libretunes/libretunes:latest; fi - - docker push registry.mregirouard.com/libretunes/libretunes --all-tags + $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA + $CI_REGISTRY_IMAGE:latest; fi + - docker push $CI_REGISTRY_IMAGE --all-tags -# Run unit tests -test: +# Run leptos tests +leptos-tests: needs: ["build"] - image: registry.mregirouard.com/libretunes/ops/docker-leptos:latest + image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest script: - cargo-leptos test +# Run all tests +tests: + needs: ["build"] + image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest + script: + - cargo test --all-targets --all-features + # Generate docs cargo-doc: needs: [] @@ -36,3 +43,36 @@ cargo-doc: artifacts: paths: - target/doc + +.argocd: + image: argoproj/argocd:v2.6.15 + before_script: + - argocd login ${ARGOCD_SERVER} --username ${ARGOCD_USERNAME} --password ${ARGOCD_PASSWORD} --grpc-web + +# Start the review environment +start-review: + extends: .argocd + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: manual + script: + - argocd app sync argocd/libretunes-review-${CI_COMMIT_SHORT_SHA} + - argocd app wait argocd/libretunes-review-${CI_COMMIT_SHORT_SHA} + environment: + name: review/$CI_COMMIT_SHORT_SHA + url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.mregirouard.com + on_stop: stop-review + +# Stop the review environment +stop-review: + needs: ["start-review"] + extends: .argocd + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: manual + allow_failure: true + script: + - argocd app delete argocd/libretunes-review-${CI_COMMIT_SHORT_SHA} --cascade + environment: + name: review/$CI_COMMIT_SHORT_SHA + action: stop diff --git a/Cargo.lock b/Cargo.lock index 59737e0..195bd07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ "actix-service", "actix-utils", "ahash 0.8.6", - "base64", + "base64 0.21.5", "bitflags 2.4.1", "brotli", "bytes", @@ -81,6 +81,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1478456bca71c7b04411da1afb0c506e16dec6863815207693b791247511027f" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.4" @@ -142,6 +158,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671404ec72194d8af58c2bdaf51e3c477a0595056bd5010148405870dda8df2" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "derive_more", + "rand", + "redis", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -219,6 +253,41 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.7" @@ -279,6 +348,12 @@ version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d19de80eff169429ac1e9f48fffb163916b448a44e8e046186232046d9e1f9" +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + [[package]] name = "askama_escape" version = "0.10.3" @@ -356,12 +431,24 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + [[package]] name = "base64" version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -547,12 +634,36 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "collection_literals" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186dce98367766de751c42c4f03970fc60fc012296e706ccbb9d5df9b6c1e271" +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "config" version = "0.13.4" @@ -564,7 +675,7 @@ dependencies = [ "nom", "pathdiff", "serde", - "toml", + "toml 0.5.11", ] [[package]] @@ -618,7 +729,14 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", "percent-encoding", + "rand", + "sha2", + "subtle", "time", "version_check", ] @@ -664,9 +782,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.14.4" @@ -709,6 +837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -747,6 +876,7 @@ dependencies = [ "itoa", "pq-sys", "r2d2", + "time", ] [[package]] @@ -761,6 +891,17 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "diesel_migrations" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + [[package]] name = "diesel_table_macro_syntax" version = "0.1.0" @@ -778,6 +919,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -971,6 +1113,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1060,6 +1212,24 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -1139,12 +1309,23 @@ version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f41f2deec9249d16ef6b1a8442fbe16013f67053797052aa0b7d2f5ebd0f0098" dependencies = [ + "icondata_ai", "icondata_bs", "icondata_cg", "icondata_core", + "icondata_io", "icondata_ri", ] +[[package]] +name = "icondata_ai" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8fe5fa2eed7715d5388e046d97f09d3baddd155b487454eb9cda3168c79d4b" +dependencies = [ + "icondata_core", +] + [[package]] name = "icondata_bs" version = "0.0.8" @@ -1169,6 +1350,15 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1640a4c1d5ddd08ab1d9854ffa7a2fa3dc52339492676b6d3031e77ca579f434" +[[package]] +name = "icondata_io" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134d9fb91cdd0e7ac971199e2c8c8eb917a975faeeee54b227a0068c4f70c886" +dependencies = [ + "icondata_core", +] + [[package]] name = "icondata_ri" version = "0.0.8" @@ -1204,6 +1394,15 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1454,7 +1653,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22207568e096ac153ba8da68635e3136c1ec614ea9012736fa861c05bfb2eeff" dependencies = [ - "base64", + "base64 0.21.5", "cfg-if", "futures", "indexmap", @@ -1534,11 +1733,15 @@ name = "libretunes" version = "0.1.0" dependencies = [ "actix-files", + "actix-identity", + "actix-session", "actix-web", "cfg-if", "console_error_panic_hook", "diesel", + "diesel_migrations", "dotenv", + "futures", "http", "lazy_static", "leptos", @@ -1547,7 +1750,9 @@ dependencies = [ "leptos_meta", "leptos_router", "openssl", + "pbkdf2", "serde", + "time", "wasm-bindgen", ] @@ -1632,6 +1837,27 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "migrations_internals" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" +dependencies = [ + "serde", + "toml 0.7.8", +] + +[[package]] +name = "migrations_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -1685,6 +1911,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "object" version = "0.32.2" @@ -1700,6 +1932,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "openssl" version = "0.10.63" @@ -1767,6 +2005,17 @@ dependencies = [ "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 = "paste" version = "1.0.14" @@ -1779,6 +2028,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1823,6 +2084,18 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2009,6 +2282,28 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redis" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "tokio", + "tokio-retry", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2062,7 +2357,7 @@ version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" dependencies = [ - "base64", + "base64 0.21.5", "bytes", "encoding_rs", "futures-core", @@ -2256,6 +2551,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "serde_test" version = "1.0.176" @@ -2338,6 +2642,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2394,6 +2709,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -2477,12 +2798,13 @@ dependencies = [ [[package]] name = "time" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" dependencies = [ "deranged", "itoa", + "num-conv", "powerfmt", "serde", "time-core", @@ -2497,10 +2819,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" +checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" dependencies = [ + "num-conv", "time-core", ] @@ -2536,6 +2859,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -2559,6 +2893,40 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower-service" version = "0.3.2" @@ -2671,6 +3039,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.0" @@ -2907,6 +3285,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cad8365489051ae9f054164e459304af2e7e9bb407c958076c8bf4aef52da5" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 5115825..d22827c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,21 @@ leptos_icons = { version = "0.1.0", default_features = false, features = [ "BsSkipEndFill", "RiPlayListMediaFill", "CgTrash", + "IoReturnUpBackSharp", + "AiEyeFilled", + "AiEyeInvisibleFilled" ] } dotenv = { version = "0.15.0", optional = true } -diesel = { version = "2.1.4", features = ["postgres", "r2d2"], optional = true } +diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], optional = true } lazy_static = { version = "1.4.0", optional = true } -serde = { versions = "1.0.195", features = ["derive"], optional = true } +serde = { versions = "1.0.195", features = ["derive"] } openssl = { version = "0.10.63", optional = true } +time = { version = "0.3.34", features = ["serde"] } +diesel_migrations = { version = "2.1.0", optional = true } +actix-identity = { version = "0.7.0", optional = true } +actix-session = { version = "0.9.0", features = ["redis-rs-session"], optional = true } +pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } +futures = { version = "0.3.30", default-features = false, optional = true } [features] csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] @@ -45,8 +54,12 @@ ssr = [ "dotenv", "diesel", "lazy_static", - "serde", "openssl", + "diesel_migrations", + "actix-identity", + "actix-session", + "pbkdf2", + "futures", ] # Defines a size-optimized profile for the WASM bundle in release mode diff --git a/Dockerfile b/Dockerfile index 9292b01..c02cd2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN npx tailwindcss -i /app/style/main.scss -o /app/style/main.scss --minify COPY assets /app/assets COPY src /app/src +COPY migrations /app/migrations # Touch files to force rebuild RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2d0865 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +name: libretunes + +services: + libretunes: + container_name: libretunes + # image: registry.mregirouard.com/libretunes/libretunes:latest + build: . + ports: + - "3000:3000" + environment: + REDIS_URL: redis://redis:6379 + POSTGRES_HOST: postgres + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - libretunes-audio:/site/audio + depends_on: + - redis + - postgres + restart: always + + redis: + container_name: redis + image: redis:latest + volumes: + - libretunes-redis:/data + restart: always + healthcheck: + test: ["CMD-SHELL", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + postgres: + container_name: postgres + image: postgres:latest + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - libretunes-postgres:/var/lib/postgresql/data + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + libretunes-audio: + libretunes-redis: + libretunes-postgres: diff --git a/migrations/2024-02-06-145714_create_artists_table/down.sql b/migrations/2024-02-06-145714_create_artists_table/down.sql new file mode 100644 index 0000000..943c085 --- /dev/null +++ b/migrations/2024-02-06-145714_create_artists_table/down.sql @@ -0,0 +1 @@ +DROP TABLE artists; diff --git a/migrations/2024-02-06-145714_create_artists_table/up.sql b/migrations/2024-02-06-145714_create_artists_table/up.sql new file mode 100644 index 0000000..73802e6 --- /dev/null +++ b/migrations/2024-02-06-145714_create_artists_table/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE artists ( + id SERIAL PRIMARY KEY UNIQUE NOT NULL, + name VARCHAR NOT NULL +); diff --git a/migrations/2024-02-06-150214_create_albums_table/down.sql b/migrations/2024-02-06-150214_create_albums_table/down.sql new file mode 100644 index 0000000..31baf23 --- /dev/null +++ b/migrations/2024-02-06-150214_create_albums_table/down.sql @@ -0,0 +1,2 @@ +DROP TABLE album_artists; +DROP TABLE albums; diff --git a/migrations/2024-02-06-150214_create_albums_table/up.sql b/migrations/2024-02-06-150214_create_albums_table/up.sql new file mode 100644 index 0000000..154bab2 --- /dev/null +++ b/migrations/2024-02-06-150214_create_albums_table/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE albums ( + id SERIAL PRIMARY KEY UNIQUE NOT NULL, + title VARCHAR NOT NULL, + release_date DATE +); + +-- A table to store artists for each album +-- Needed because an album can have multiple artists, but in Postgres we can't store an array of foreign keys +CREATE TABLE album_artists ( + album_id INTEGER REFERENCES albums(id) ON DELETE CASCADE NOT NULL, + artist_id INTEGER REFERENCES artists(id) ON DELETE CASCADE NULL, + PRIMARY KEY (album_id, artist_id) +); diff --git a/migrations/2024-02-06-150334_create_songs_table/down.sql b/migrations/2024-02-06-150334_create_songs_table/down.sql new file mode 100644 index 0000000..b5ef474 --- /dev/null +++ b/migrations/2024-02-06-150334_create_songs_table/down.sql @@ -0,0 +1,2 @@ +DROP TABLE song_artists; +DROP TABLE songs; diff --git a/migrations/2024-02-06-150334_create_songs_table/up.sql b/migrations/2024-02-06-150334_create_songs_table/up.sql new file mode 100644 index 0000000..91249a1 --- /dev/null +++ b/migrations/2024-02-06-150334_create_songs_table/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE songs ( + id SERIAL PRIMARY KEY UNIQUE NOT NULL, + title VARCHAR NOT NULL, + album_id INTEGER REFERENCES albums(id), + track INTEGER, + duration INTEGER NOT NULL, + release_date DATE, + storage_path VARCHAR NOT NULL, + image_path VARCHAR +); + +CREATE TABLE song_artists ( + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + artist_id INTEGER REFERENCES artists(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (song_id, artist_id) +); diff --git a/migrations/2024-02-16-064035_create_pg_trgm/down.sql b/migrations/2024-02-16-064035_create_pg_trgm/down.sql new file mode 100644 index 0000000..47fd365 --- /dev/null +++ b/migrations/2024-02-16-064035_create_pg_trgm/down.sql @@ -0,0 +1 @@ +DROP EXTENSION pg_trgm; diff --git a/migrations/2024-02-16-064035_create_pg_trgm/up.sql b/migrations/2024-02-16-064035_create_pg_trgm/up.sql new file mode 100644 index 0000000..d497121 --- /dev/null +++ b/migrations/2024-02-16-064035_create_pg_trgm/up.sql @@ -0,0 +1 @@ +CREATE EXTENSION pg_trgm; diff --git a/migrations/2024-02-16-191139_create_title_indicies/down.sql b/migrations/2024-02-16-191139_create_title_indicies/down.sql new file mode 100644 index 0000000..1306204 --- /dev/null +++ b/migrations/2024-02-16-191139_create_title_indicies/down.sql @@ -0,0 +1,3 @@ +DROP INDEX artists_name_idx; +DROP INDEX albums_title_idx; +DROP INDEX songs_title_idx; diff --git a/migrations/2024-02-16-191139_create_title_indicies/up.sql b/migrations/2024-02-16-191139_create_title_indicies/up.sql new file mode 100644 index 0000000..61e63ab --- /dev/null +++ b/migrations/2024-02-16-191139_create_title_indicies/up.sql @@ -0,0 +1,3 @@ +CREATE INDEX artists_name_idx ON artists USING GIST (name gist_trgm_ops); +CREATE INDEX albums_title_idx ON albums USING GIST (title gist_trgm_ops); +CREATE INDEX songs_title_idx ON songs USING GIST (title gist_trgm_ops); diff --git a/src/app.rs b/src/app.rs index 2a1d320..6df1766 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,8 @@ use crate::queue::Queue; use leptos::*; use leptos_meta::*; use leptos_router::*; +use crate::pages::login::*; +use crate::pages::signup::*; #[component] pub fn App() -> impl IntoView { @@ -24,6 +26,8 @@ pub fn App() -> impl IntoView { + + diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..1995deb --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,71 @@ +use leptos::*; +use crate::models::User; + +/// Create a new user and log them in +/// Takes in a NewUser struct, with the password in plaintext +/// Returns a Result with the error message if the user could not be created +#[server(endpoint = "signup")] +pub async fn signup(new_user: User) -> Result<(), ServerFnError> { + use crate::users::create_user; + + use leptos_actix::extract; + use actix_web::{HttpMessage, HttpRequest}; + use actix_identity::Identity; + + // Ensure the user has no id + let new_user = User { + id: None, + ..new_user + }; + + create_user(&new_user).await + .map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?; + + extract(|request: HttpRequest| async move { + Identity::login(&request.extensions(), new_user.username.clone()) + }).await??; + + Ok(()) +} + +/// Log a user in +/// Takes in a username or email and a password in plaintext +/// Returns a Result with a boolean indicating if the login was successful +#[server(endpoint = "login")] +pub async fn login(username_or_email: String, password: String) -> Result { + use crate::users::validate_user; + use actix_web::{HttpMessage, HttpRequest}; + use actix_identity::Identity; + use leptos_actix::extract; + + let possible_user = validate_user(username_or_email, password).await + .map_err(|e| ServerFnError::ServerError(format!("Error validating user: {}", e)))?; + + let user = match possible_user { + Some(user) => user, + None => return Ok(false) + }; + + extract(|request: HttpRequest| async move { + Identity::login(&request.extensions(), user.username.clone()) + }).await??; + + Ok(true) +} + +/// Log a user out +/// Returns a Result with the error message if the user could not be logged out +#[server(endpoint = "logout")] +pub async fn logout() -> Result<(), ServerFnError> { + use leptos_actix::extract; + use actix_identity::Identity; + + extract(|user: Option| async move { + if let Some(user) = user { + user.logout(); + } + }).await?; + + Ok(()) +} + diff --git a/src/build.rs b/src/build.rs index 32eb98c..64a96cb 100644 --- a/src/build.rs +++ b/src/build.rs @@ -6,4 +6,6 @@ fn main() { "cargo:rustc-cfg=target=\"{}\"", std::env::var("TARGET").unwrap() ); + + println!("cargo:rerun-if-changed=migrations"); } diff --git a/src/database.rs b/src/database.rs index a90de44..cb1bee1 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,4 +1,5 @@ use cfg_if::cfg_if; +use leptos::logging::log; cfg_if! { if #[cfg(feature = "ssr")] { @@ -12,6 +13,12 @@ use diesel::{ r2d2::Pool, }; +use diesel_migrations::{ + embed_migrations, + EmbeddedMigrations, + MigrationHarness, +}; + // See https://leward.eu/notes-on-diesel-a-rust-orm/ // Define some types to make it easier to work with Diesel @@ -25,12 +32,59 @@ lazy_static! { /// Initialize the database pool /// -/// Will panic if the DATABASE_URL environment variable is not set, or if there is an error creating the pool. +/// Uses DATABASE_URL environment variable to connect to the database if set, +/// otherwise builds a connection string from other environment variables. +/// +/// Will panic if either the DATABASE_URL or POSTGRES_HOST environment variables +/// are not set, or if there is an error creating the pool. /// /// # Returns /// A database pool object, which can be used to get pooled connections fn init_db_pool() -> PgPool { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { + // Build the database URL from environment variables + // Construct a separate log_url to avoid logging the password + let mut log_url = "postgres://".to_string(); + let mut url = "postgres://".to_string(); + + if let Ok(user) = env::var("POSTGRES_USER") { + url.push_str(&user); + log_url.push_str(&user); + + if let Ok(password) = env::var("POSTGRES_PASSWORD") { + url.push_str(":"); + log_url.push_str(":"); + url.push_str(&password); + log_url.push_str("********"); + } + + url.push_str("@"); + log_url.push_str("@"); + } + + let host = env::var("POSTGRES_HOST").expect("DATABASE_URL or POSTGRES_HOST must be set"); + + url.push_str(&host); + log_url.push_str(&host); + + if let Ok(port) = env::var("POSTGRES_PORT") { + url.push_str(":"); + url.push_str(&port); + log_url.push_str(":"); + log_url.push_str(&port); + } + + if let Ok(dbname) = env::var("POSTGRES_DB") { + url.push_str("/"); + url.push_str(&dbname); + log_url.push_str("/"); + log_url.push_str(&dbname); + } + + log!("Connecting to database: {}", log_url); + url + }); + let manager = ConnectionManager::::new(database_url); PgPool::builder() .build(manager) @@ -47,5 +101,15 @@ pub fn get_db_conn() -> PgPooledConn { DB_POOL.get().expect("Failed to get a database connection from the pool.") } +/// Embedded database migrations into the binary +const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!(); + +/// Run any pending migrations in the database +/// Always safe to call, as it will only run migrations that have not already been run +pub fn migrate() { + let db_con = &mut get_db_conn(); + db_con.run_pending_migrations(DB_MIGRATIONS).expect("Could not run database migrations"); +} + } } diff --git a/src/lib.rs b/src/lib.rs index 2670b01..95ab806 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,23 @@ pub mod app; +pub mod auth; pub mod songdata; pub mod playstatus; pub mod playbar; pub mod database; pub mod queue; pub mod song; +pub mod models; +pub mod pages; +pub mod users; +pub mod search; use cfg_if::cfg_if; +cfg_if! { + if #[cfg(feature = "ssr")] { + pub mod schema; + } +} + cfg_if! { if #[cfg(feature = "hydrate")] { diff --git a/src/main.rs b/src/main.rs index 91dde65..f6a448f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,12 +8,32 @@ extern crate openssl; #[macro_use] extern crate diesel; +#[cfg(feature = "ssr")] +extern crate diesel_migrations; + #[cfg(feature = "ssr")] #[actix_web::main] async fn main() -> std::io::Result<()> { + use actix_identity::IdentityMiddleware; + use actix_session::storage::RedisSessionStore; + use actix_session::SessionMiddleware; + use actix_web::cookie::Key; + use dotenv::dotenv; dotenv().ok(); + // Bring the database up to date + libretunes::database::migrate(); + + let session_secret_key = if let Ok(key) = std::env::var("SESSION_SECRET_KEY") { + Key::from(key.as_bytes()) + } else { + Key::generate() + }; + + let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set"); + let redis_store = RedisSessionStore::new(redis_url).await.unwrap(); + use actix_files::Files; use actix_web::*; use leptos::*; @@ -40,6 +60,8 @@ async fn main() -> std::io::Result<()> { .service(favicon) .leptos_routes(leptos_options.to_owned(), routes.to_owned(), App) .app_data(web::Data::new(leptos_options.to_owned())) + .wrap(IdentityMiddleware::default()) + .wrap(SessionMiddleware::new(redis_store.clone(), session_secret_key.clone())) //.wrap(middleware::Compress::default()) }) .bind(&addr)? diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..7ad0cda --- /dev/null +++ b/src/models.rs @@ -0,0 +1,292 @@ +use std::time::SystemTime; +use std::error::Error; +use time::Date; +use serde::{Deserialize, Serialize}; + +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(feature = "ssr")] { + use diesel::prelude::*; + use crate::database::PgPooledConn; + } +} + +// These "models" are used to represent the data in the database +// Diesel uses these models to generate the SQL queries that are used to interact with the database. +// These types are also used for API endpoints, for consistency. Because the file must be compiled +// for both the server and the client, we use the `cfg_attr` attribute to conditionally add +// diesel-specific attributes to the models when compiling for the server + +/// Model for a "User", used for querying the database +/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval +/// Using deserialize_as makes Diesel use the specified type when deserializing from the database, +/// and then call .into() to convert it into the Option +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct User { + /// A unique id for the user + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + // #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released + pub id: Option, + /// The user's username + pub username: String, + /// The user's email + pub email: String, + /// The user's password, stored as a hash + #[cfg_attr(feature = "ssr", diesel(deserialize_as = String))] + pub password: Option, + /// The time the user was created + #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] + pub created_at: Option, +} + +/// Model for an artist +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct Artist { + /// A unique id for the artist + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option, + /// The artist's name + pub name: String, +} + +impl Artist { + /// Add an album to this artist in the database + /// + /// # Arguments + /// + /// * `new_album_id` - The id of the album to add to this artist + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<(), Box>` - A result indicating success with an empty value, or an error + /// + #[cfg(feature = "ssr")] + pub fn add_album(self: &Self, new_album_id: i32, conn: &mut PgPooledConn) -> Result<(), Box> { + use crate::schema::album_artists::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?; + + diesel::insert_into(album_artists) + .values((album_id.eq(new_album_id), artist_id.eq(my_id))) + .execute(conn)?; + + Ok(()) + } + + /// Get albums by artist from the database + /// + /// The `id` field of this artist must be present (Some) to get albums + /// + /// # Arguments + /// + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result, Box>` - A result indicating success with a vector of albums, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_albums(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::albums::dsl::*; + use crate::schema::album_artists::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to get albums")?; + + let my_albums = albums + .inner_join(album_artists) + .filter(artist_id.eq(my_id)) + .select(albums::all_columns()) + .load(conn)?; + + Ok(my_albums) + } + + /// Add a song to this artist in the database + /// + /// The `id` field of this artist must be present (Some) to add a song + /// + /// # Arguments + /// + /// * `new_song_id` - The id of the song to add to this artist + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<(), Box>` - A result indicating success with an empty value, or an error + /// + #[cfg(feature = "ssr")] + pub fn add_song(self: &Self, new_song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box> { + use crate::schema::song_artists::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?; + + diesel::insert_into(song_artists) + .values((song_id.eq(new_song_id), artist_id.eq(my_id))) + .execute(conn)?; + + Ok(()) + } + + /// Get songs by this artist from the database + /// + /// The `id` field of this artist must be present (Some) to get songs + /// + /// # Arguments + /// + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result, Box>` - A result indicating success with a vector of songs, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_artists::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to get songs")?; + + let my_songs = songs + .inner_join(song_artists) + .filter(artist_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } +} + +/// Model for an album +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::albums))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct Album { + /// A unique id for the album + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option, + /// The album's title + pub title: String, + /// The album's release date + pub release_date: Option, +} + +impl Album { + /// Add an artist to this album in the database + /// + /// The `id` field of this album must be present (Some) to add an artist + /// + /// # Arguments + /// + /// * `new_artist_id` - The id of the artist to add to this album + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<(), Box>` - A result indicating success with an empty value, or an error + /// + #[cfg(feature = "ssr")] + pub fn add_artist(self: &Self, new_artist_id: i32, conn: &mut PgPooledConn) -> Result<(), Box> { + use crate::schema::album_artists::dsl::*; + + let my_id = self.id.ok_or("Album id must be present (Some) to add an artist")?; + + diesel::insert_into(album_artists) + .values((album_id.eq(my_id), artist_id.eq(new_artist_id))) + .execute(conn)?; + + Ok(()) + } + + /// Get songs by this artist from the database + /// + /// The `id` field of this album must be present (Some) to get songs + /// + /// # Arguments + /// + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result, Box>` - A result indicating success with a vector of songs, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_artists::dsl::*; + + let my_id = self.id.ok_or("Album id must be present (Some) to get songs")?; + + let my_songs = songs + .inner_join(song_artists) + .filter(album_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } +} + +/// Model for a song +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct Song { + /// A unique id for the song + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option, + /// The song's title + pub title: String, + /// The album the song is from + pub album_id: Option, + /// The track number of the song on the album + pub track: Option, + /// The duration of the song in seconds + pub duration: i32, + /// The song's release date + pub release_date: Option, + /// The path to the song's audio file + pub storage_path: String, + /// The path to the song's image file + pub image_path: Option, +} + +impl Song { + /// Add an artist to this song in the database + /// + /// The `id` field of this song must be present (Some) to add an artist + /// + /// # Arguments + /// + /// * `new_artist_id` - The id of the artist to add to this song + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result, Box>` - A result indicating success with an empty value, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_artists(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::artists::dsl::*; + use crate::schema::song_artists::dsl::*; + + let my_id = self.id.ok_or("Song id must be present (Some) to get artists")?; + + let my_artists = artists + .inner_join(song_artists) + .filter(song_id.eq(my_id)) + .select(artists::all_columns()) + .load(conn)?; + + Ok(my_artists) + } +} diff --git a/src/pages.rs b/src/pages.rs new file mode 100644 index 0000000..40f63fd --- /dev/null +++ b/src/pages.rs @@ -0,0 +1,2 @@ +pub mod login; +pub mod signup; \ No newline at end of file diff --git a/src/pages/login.rs b/src/pages/login.rs new file mode 100644 index 0000000..0d55a26 --- /dev/null +++ b/src/pages/login.rs @@ -0,0 +1,91 @@ +use crate::auth::login; +use leptos::leptos_dom::*; +use leptos::*; +use leptos_icons::AiIcon::*; +use leptos_icons::IoIcon::*; +use leptos_icons::*; + +#[component] +pub fn Login() -> impl IntoView { + let (username_or_email, set_username_or_email) = create_signal("".to_string()); + let (password, set_password) = create_signal("".to_string()); + + let (show_password, set_show_password) = create_signal(false); + + let toggle_password = move |_| { + set_show_password.update(|show_password| *show_password = !*show_password); + log!("showing password"); + }; + + let on_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + let username_or_email1 = username_or_email.get(); + let password1 = password.get(); + + spawn_local(async move { + let login_result = login(username_or_email1, password1).await; + if let Err(err) = login_result { + // Handle the error here, e.g., log it or display to the user + log!("Error logging in: {:?}", err); + } else if let Ok(true) = login_result { + // Redirect to the login page + log!("Logged in Successfully!"); + leptos_router::use_navigate()("/", Default::default()); + log!("Navigated to home page after login"); + } else if let Ok(false) = login_result { + log!("Invalid username or password"); + } + }); + }; + + view! { +
+ +
+ } +} diff --git a/src/pages/signup.rs b/src/pages/signup.rs new file mode 100644 index 0000000..c2de8f8 --- /dev/null +++ b/src/pages/signup.rs @@ -0,0 +1,104 @@ +use crate::auth::signup; +use crate::models::User; +use leptos::ev::input; +use leptos::leptos_dom::*; +use leptos::*; +use leptos_icons::AiIcon::*; +use leptos_icons::IoIcon::*; +use leptos_icons::*; + +#[component] +pub fn Signup() -> impl IntoView { + let (username, set_username) = create_signal("".to_string()); + let (email, set_email) = create_signal("".to_string()); + let (password, set_password) = create_signal("".to_string()); + + let (show_password, set_show_password) = create_signal(false); + + let navigate = leptos_router::use_navigate(); + + let toggle_password = move |_| { + set_show_password.update(|show_password| *show_password = !*show_password); + log!("showing password"); + }; + + let on_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + let new_user = User { + id: None, + username: username.get(), + email: email.get(), + password: Some(password.get()), + created_at: None, + }; + log!("new user: {:?}", new_user); + + spawn_local(async move { + if let Err(err) = signup(new_user).await { + // Handle the error here, e.g., log it or display to the user + log!("Error signing up: {:?}", err); + } else { + // Redirect to the login page + log!("Signed up successfully!"); + leptos_router::use_navigate()("/", Default::default()); + log!("Navigated to home page after signup") + } + }); + }; + + view! { +
+ +
+ } +} diff --git a/src/schema.rs b/src/schema.rs index 2e9b462..b98d736 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,5 +1,47 @@ // @generated automatically by Diesel CLI. +diesel::table! { + album_artists (album_id, artist_id) { + album_id -> Int4, + artist_id -> Int4, + } +} + +diesel::table! { + albums (id) { + id -> Int4, + title -> Varchar, + release_date -> Nullable, + } +} + +diesel::table! { + artists (id) { + id -> Int4, + name -> Varchar, + } +} + +diesel::table! { + song_artists (song_id, artist_id) { + song_id -> Int4, + artist_id -> Int4, + } +} + +diesel::table! { + songs (id) { + id -> Int4, + title -> Varchar, + album_id -> Nullable, + track -> Nullable, + duration -> Int4, + release_date -> Nullable, + storage_path -> Varchar, + image_path -> Nullable, + } +} + diesel::table! { users (id) { id -> Int4, @@ -9,3 +51,18 @@ diesel::table! { created_at -> Timestamp, } } + +diesel::joinable!(album_artists -> albums (album_id)); +diesel::joinable!(album_artists -> artists (artist_id)); +diesel::joinable!(song_artists -> artists (artist_id)); +diesel::joinable!(song_artists -> songs (song_id)); +diesel::joinable!(songs -> albums (album_id)); + +diesel::allow_tables_to_appear_in_same_query!( + album_artists, + albums, + artists, + song_artists, + songs, + users, +); diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..3b24f08 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,109 @@ +use leptos::*; +use crate::models::{Artist, Album, Song}; + +use cfg_if::cfg_if; + +cfg_if! { +if #[cfg(feature = "ssr")] { + use diesel::sql_types::*; + use diesel::*; + use diesel::pg::Pg; + use diesel::expression::AsExpression; + + use crate::database::get_db_conn; + + // Define pg_trgm operators + // Functions do not use indices for queries, so we need to use operators + diesel::infix_operator!(Similarity, " % ", backend: Pg); + diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg); + + // Create functions to make use of the operators in queries + fn trgm_similar, U: AsExpression>(left: T, right: U) + -> Similarity { + Similarity::new(left.as_expression(), right.as_expression()) + } + + fn trgm_distance, U: AsExpression>(left: T, right: U) + -> Distance { + Distance::new(left.as_expression(), right.as_expression()) + } +} +} + +/// Search for albums by title +/// +/// # Arguments +/// `query` - The search query. This will be used to perform a fuzzy search on the album titles +/// `limit` - The maximum number of results to return +/// +/// # Returns +/// A Result containing a vector of albums if the search was successful, or an error if the search failed +#[server(endpoint = "search_albums")] +pub async fn search_albums(query: String, limit: i64) -> Result, ServerFnError> { + use crate::schema::albums::dsl::*; + + Ok(albums + .filter(trgm_similar(title, query.clone())) + .order_by(trgm_distance(title, query)) + .limit(limit) + .load(&mut get_db_conn())?) +} + +/// Search for artists by name +/// +/// # Arguments +/// `query` - The search query. This will be used to perform a fuzzy search on the artist names +/// `limit` - The maximum number of results to return +/// +/// # Returns +/// A Result containing a vector of artists if the search was successful, or an error if the search failed +#[server(endpoint = "search_artists")] +pub async fn search_artists(query: String, limit: i64) -> Result, ServerFnError> { + use crate::schema::artists::dsl::*; + + Ok(artists + .filter(trgm_similar(name, query.clone())) + .order_by(trgm_distance(name, query)) + .limit(limit) + .load(&mut get_db_conn())?) +} + +/// Search for songs by title +/// +/// # Arguments +/// `query` - The search query. This will be used to perform a fuzzy search on the song titles +/// `limit` - The maximum number of results to return +/// +/// # Returns +/// A Result containing a vector of songs if the search was successful, or an error if the search failed +#[server(endpoint = "search_songs")] +pub async fn search_songs(query: String, limit: i64) -> Result, ServerFnError> { + use crate::schema::songs::dsl::*; + + Ok(songs + .filter(trgm_similar(title, query.clone())) + .order_by(trgm_distance(title, query)) + .limit(limit) + .load(&mut get_db_conn())?) +} + +/// Search for songs, albums, and artists by title or name +/// +/// # Arguments +/// `query` - The search query. This will be used to perform a fuzzy search on the +/// song titles, album titles, and artist names +/// `limit` - The maximum number of results to return for each type +/// +/// # Returns +/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful, +#[server(endpoint = "search")] +pub async fn search(query: String, limit: i64) -> Result<(Vec, Vec, Vec), ServerFnError> { + let albums = search_albums(query.clone(), limit); + let artists = search_artists(query.clone(), limit); + let songs = search_songs(query.clone(), limit); + + use futures::join; + + let (albums, artists, songs) = join!(albums, artists, songs); + Ok((albums?, artists?, songs?)) +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..7d81b31 --- /dev/null +++ b/src/users.rs @@ -0,0 +1,104 @@ +cfg_if::cfg_if! { + if #[cfg(feature = "ssr")] { + use diesel::prelude::*; + use crate::database::get_db_conn; + + use pbkdf2::{ + password_hash::{ + rand_core::OsRng, + PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error + }, + Pbkdf2 + }; + } +} + +use leptos::*; +use crate::models::User; + +/// Get a user from the database by username or email +/// Returns a Result with the user if found, None if not found, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn find_user(username_or_email: String) -> Result, ServerFnError> { + use crate::schema::users::dsl::*; + + // Look for either a username or email that matches the input, and return an option with None if no user is found + let db_con = &mut get_db_conn(); + let user = users.filter(username.eq(username_or_email.clone())).or_filter(email.eq(username_or_email)) + .first::(db_con).optional() + .map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?; + + Ok(user) +} + +/// Create a new user in the database +/// Returns an empty Result if successful, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn create_user(new_user: &User) -> Result<(), ServerFnError> { + use crate::schema::users::dsl::*; + + let new_password = new_user.password.clone() + .ok_or(ServerFnError::ServerError(format!("No password provided for user {}", new_user.username)))?; + + let salt = SaltString::generate(&mut OsRng); + let password_hash = Pbkdf2.hash_password(new_password.as_bytes(), &salt) + .map_err(|_| ServerFnError::ServerError("Error hashing password".to_string()))?.to_string(); + + let new_user = User { + password: Some(password_hash), + ..new_user.clone() + }; + + let db_con = &mut get_db_conn(); + + diesel::insert_into(users).values(&new_user).execute(db_con) + .map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?; + + Ok(()) +} + +/// Validate a user's credentials +/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem +#[cfg(feature = "ssr")] +pub async fn validate_user(username_or_email: String, password: String) -> Result, ServerFnError> { + let db_user = find_user(username_or_email.clone()).await + .map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?; + + // If the user is not found, return None + let db_user = match db_user { + Some(user) => user, + None => return Ok(None) + }; + + let db_password = db_user.password.clone() + .ok_or(ServerFnError::ServerError(format!("No password found for user {}", db_user.username)))?; + + let password_hash = PasswordHash::new(&db_password) + .map_err(|e| ServerFnError::ServerError(format!("Error hashing supplied password: {}", e)))?; + + match Pbkdf2.verify_password(password.as_bytes(), &password_hash) { + Ok(()) => {}, + Err(Error::Password) => { + return Ok(None); + }, + Err(e) => { + return Err(ServerFnError::ServerError(format!("Error verifying password: {}", e))); + } + } + + Ok(Some(db_user)) +} + +/// Get a user from the database by username or email +/// Returns a Result with the user if found, None if not found, or an error if there was a problem +#[server(endpoint = "get_user")] +pub async fn get_user(username_or_email: String) -> Result, ServerFnError> { + let mut user = find_user(username_or_email).await?; + + // Remove the password hash before returning the user + if let Some(user) = user.as_mut() { + user.password = None; + } + + Ok(user) +} diff --git a/style/login.scss b/style/login.scss new file mode 100644 index 0000000..e694d3a --- /dev/null +++ b/style/login.scss @@ -0,0 +1,151 @@ +@import "theme.scss"; + +.login-container { + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + top: 50%; + left: 50%; + width: 27rem; + height: 30rem; + transform: translate(-50%, -50%); + background: $auth-containers; + z-index: 1; + border-radius: 8px; + overflow: hidden; +} + +.login-container .header h1 { + margin-top: 3rem; + font-size: 2.5rem; + color: $accent-color; +} +.login-container .login-form { + width: 75%; +} +.login-form .input-box:first-child { + margin-top: 1rem; +} +.login-form .input-box { + position: relative; + margin-top: 3rem; +} +.login-form .input-box input { + position: relative; + width: 100%; + max-width: 34vw; + padding: 17px 0px 10px; + background: transparent; + outline: none; + border: none; + box-shadow: none; + color: #23242a; + font-size: 1.1em; + font-family: "Roboto", sans-serif; + font-weight: 400; + letter-spacing: 0px; + text-indent: 10px; + vertical-align: middle; + z-index: 10; + color: #fff; +} +.login-form .input-box span { + position: absolute; + left: 0; + padding: 15px 0px 10px; + pointer-events: none; + color: black; + font-size: 1.19em; + letter-spacing: 0.5px; + transition: 0.5s; +} +.login-form .input-box input:valid ~ span, +.login-form .input-box input:focus ~ span { + color: rgb(94, 93, 93); + font-size: 0.9rem; + transform: translateY(-30px); + font-weight: 400; +} +.login-form .input-box i { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: $auth-inputs; + border-radius: 4px; + overflow: hidden; + transition: 0.5s; + pointer-events: none; +} + +.login-form .input-box input:valid ~ i, +.login-form .input-box input:focus ~ i { + height: 2.6rem; +} +.login-form .forgot-pw { + display: inline-flex; + margin-top: 3px; + font-size: 0.9rem; + cursor: pointer; + color: #8f8f8f; + text-decoration: underline; +} +.login-form .forgot-pw:hover { + color: #fff; + transition: all 0.2s; +} +.login-form input[type="submit"] { + margin-top: 3rem; + width: 100%; + height: 3rem; + border: none; + border-radius: 8px; + color: rgb(210, 207, 207); + cursor: pointer; + font-size: 1.1rem; + font-weight: 600; + background-color: $accent-color; +} +.login-form .go-to-signup { + color: #8f8f8f; + font-size: 0.9rem; +} +.login-form .go-to-signup a { + cursor: pointer; + color: #8f8f8f; + text-decoration: underline; +} +.login-form .go-to-signup a:hover { + color: black; + transition: all 0.2s; +} +.login-container .return { + position: absolute; + left: 10px; + top: 10px; + font-size: 1.8rem; + color: white; + cursor: pointer; + transition: all 0.3s; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0.3rem; +} +.login-container .return:hover { + background-color: rgba(0, 0, 0, 0.4); +} +.login-password-visibility { + position: absolute; + font-size: 1.7rem; + top: 28%; + right: 5px; + z-index: 5; + cursor: pointer; + border: none; + background-color: transparent; + color: white; +} diff --git a/style/main.scss b/style/main.scss index e496880..6706ef1 100644 --- a/style/main.scss +++ b/style/main.scss @@ -1,6 +1,8 @@ @import 'playbar.scss'; @import 'theme.scss'; @import 'queue.scss'; +@import 'login.scss'; +@import 'signup.scss'; body { font-family: sans-serif; diff --git a/style/signup.scss b/style/signup.scss new file mode 100644 index 0000000..30f87dc --- /dev/null +++ b/style/signup.scss @@ -0,0 +1,151 @@ +@import "theme.scss"; + +.auth-page-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; + background-color: black; +} + +.signup-container { + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + top: 50%; + left: 50%; + width: 27rem; + height: 35rem; + transform: translate(-50%, -50%); + background: $auth-containers; + z-index: 1; + border-radius: 8px; + overflow: hidden; +} +.signup-container .header h1 { + margin-top: 3rem; + font-size: 2.5rem; + color: $accent-color; +} +.signup-container .signup-form { + width: 80%; +} +.signup-form .input-box { + position: relative; + margin-top: 3rem; +} +.signup-form .input-box:first-child { + margin-top: 0.7rem; +} +.signup-form .input-box input { + position: relative; + width: 100%; + max-width: 34vw; + padding: 17px 0px 10px; + background: transparent; + outline: none; + border: none; + box-shadow: none; + color: #23242a; + font-size: 1.1em; + font-family: "Roboto", sans-serif; + font-weight: 400; + letter-spacing: 0px; + text-indent: 10px; + vertical-align: middle; + z-index: 10; + color: #fff; +} +.signup-form .input-box span { + position: absolute; + left: 0; + padding: 15px 0px 10px; + pointer-events: none; + color: black; + font-size: 1.19em; + letter-spacing: 0.5px; + transition: 0.5s; +} +.signup-form .input-box input:valid ~ span, +.signup-form .input-box input:focus ~ span { + color: rgb(94, 93, 93); + font-size: 0.9rem; + transform: translateY(-30px); + font-weight: 400; +} +.signup-form .input-box i { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: $auth-inputs; + border-radius: 4px; + overflow: hidden; + transition: 0.5s; + pointer-events: none; +} + +.signup-form .input-box input:valid ~ i, +.signup-form .input-box input:focus ~ i { + height: 2.6rem; +} + +.signup-form input[type="submit"] { + margin-top: 3.5rem; + width: 100%; + height: 45px; + border: none; + border-radius: 8px; + color: white; + cursor: pointer; + font-size: 1.1rem; + font-weight: 600; + background-color: $accent-color; +} +.signup-form .go-to-login { + color: #8f8f8f; + font-size: 0.9rem; +} +.signup-form .go-to-login a { + cursor: pointer; + color: #8f8f8f; + text-decoration: underline; +} +.signup-form .go-to-login a:hover { + color: black; + transition: all 0.2s; +} +.signup-container .return { + position: absolute; + left: 10px; + top: 10px; + font-size: 1.8rem; + color: white; + cursor: pointer; + transition: all 0.3s; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0.3rem; +} +.signup-container .return:hover { + background-color: rgba(0, 0, 0, 0.4); +} +.password-visibility { + position: absolute; + font-size: 1.7rem; + top: 28%; + right: 5px; + z-index: 5; + cursor: pointer; + border: none; + background-color: transparent; + color: white; +} +.pw-requirements { + font-size: 0.7rem; +} diff --git a/style/theme.scss b/style/theme.scss index 63ff157..2d80285 100644 --- a/style/theme.scss +++ b/style/theme.scss @@ -7,3 +7,6 @@ $play-bar-background-color: #212121; $play-grad-start: #0a0533; $play-grad-end: $accent-color; $queue-background-color: $play-bar-background-color; + +$auth-inputs: #796dd4; +$auth-containers: white;