Merge branch 'main' into 1-add-queue

This commit is contained in:
Ethan Girouard 2024-03-01 02:20:15 -05:00
commit 03ba18e0b8
Signed by: eta357
GPG Key ID: 7BCDC36DFD11C146
34 changed files with 1807 additions and 22 deletions

View File

@ -3,6 +3,7 @@
# Except:
!/assets
!/migrations
!/src
!/style
!/Cargo.lock

3
.gitignore vendored
View File

@ -27,3 +27,6 @@ playwright/.cache/
# Environment variables
.env
# Sass cache
.sass-cache

View File

@ -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

403
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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

54
docker-compose.yml Normal file
View File

@ -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:

View File

@ -0,0 +1 @@
DROP TABLE artists;

View File

@ -0,0 +1,4 @@
CREATE TABLE artists (
id SERIAL PRIMARY KEY UNIQUE NOT NULL,
name VARCHAR NOT NULL
);

View File

@ -0,0 +1,2 @@
DROP TABLE album_artists;
DROP TABLE albums;

View File

@ -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)
);

View File

@ -0,0 +1,2 @@
DROP TABLE song_artists;
DROP TABLE songs;

View File

@ -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)
);

View File

@ -0,0 +1 @@
DROP EXTENSION pg_trgm;

View File

@ -0,0 +1 @@
CREATE EXTENSION pg_trgm;

View File

@ -0,0 +1,3 @@
DROP INDEX artists_name_idx;
DROP INDEX albums_title_idx;
DROP INDEX songs_title_idx;

View File

@ -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);

View File

@ -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 {
<Routes>
<Route path="" view=HomePage/>
<Route path="/*any" view=NotFound/>
<Route path="/login" view=Login />
<Route path="/signup" view=Signup />
</Routes>
</main>
</Router>

71
src/auth.rs Normal file
View File

@ -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<bool, ServerFnError> {
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<Identity>| async move {
if let Some(user) = user {
user.logout();
}
}).await?;
Ok(())
}

View File

@ -6,4 +6,6 @@ fn main() {
"cargo:rustc-cfg=target=\"{}\"",
std::env::var("TARGET").unwrap()
);
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -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::<PgConnection>::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");
}
}
}

View File

@ -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")] {

View File

@ -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)?

292
src/models.rs Normal file
View File

@ -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<i32>,
/// 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<String>,
/// The time the user was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))]
pub created_at: Option<SystemTime>,
}
/// 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<i32>,
/// 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<dyn Error>>` - 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<dyn Error>> {
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<Vec<Album>, Box<dyn Error>>` - 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<Vec<Album>, Box<dyn Error>> {
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<dyn Error>>` - 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<dyn Error>> {
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<Vec<Song>, Box<dyn Error>>` - 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<Vec<Song>, Box<dyn Error>> {
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<i32>,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<Date>,
}
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<dyn Error>>` - 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<dyn Error>> {
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<Vec<Song>, Box<dyn Error>>` - 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<Vec<Song>, Box<dyn Error>> {
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<i32>,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<Date>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
}
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<Vec<Artist>, Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn get_artists(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Artist>, Box<dyn Error>> {
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)
}
}

2
src/pages.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod login;
pub mod signup;

91
src/pages/login.rs Normal file
View File

@ -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! {
<div class="auth-page-container">
<div class="login-container">
<a class="return" href="/"><Icon icon=Icon::from(IoReturnUpBackSharp) /></a>
<div class="header">
<h1>LibreTunes</h1>
</div>
<form class="login-form" action="POST" on:submit=on_submit>
<div class="input-box">
<input class="login-info" type="text" required
on:input = move |ev| {
set_username_or_email(event_target_value(&ev));
log!("username/email changed to: {}", username_or_email.get());
}
prop:value=username_or_email
/>
<span>Username/Email</span>
<i></i>
</div>
<div class="input-box">
<input class="login-password" type={move || if show_password() { "text" } else { "password"} } required
on:input = move |ev| {
set_password(event_target_value(&ev));
log!("password changed to: {}", password.get());
}
/>
<span>Password</span>
<i></i>
<Show
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="login-password-visibility">
<Icon icon=Icon::from(AiEyeInvisibleFilled) />
</button> /> }
>
<button on:click=toggle_password class="login-password-visibility">
<Icon icon=Icon::from(AiEyeFilled) />
</button>
</Show>
</div>
<a href="" class="forgot-pw">Forgot Password?</a>
<input type="submit" value="Login" />
<span class="go-to-signup">
New here? <a href="/signup">Create an Account</a>
</span>
</form>
</div>
</div>
}
}

104
src/pages/signup.rs Normal file
View File

@ -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! {
<div class="auth-page-container">
<div class="signup-container">
<a class="return" href="/"><Icon icon=Icon::from(IoReturnUpBackSharp) /></a>
<div class="header">
<h1>LibreTunes</h1>
</div>
<form class="signup-form" action="POST" on:submit=on_submit>
<div class="input-box">
<input class="signup-email" type="text" required
on:input = move |ev| {
set_email(event_target_value(&ev));
log!("email changed to: {}", email.get());
}
prop:value=email
/>
<span>Email</span>
<i></i>
</div>
<div class="input-box">
<input class="signup-username" type="text" required
on:input = move |ev| {
set_username(event_target_value(&ev));
log!("username changed to: {}", username.get());
}
/>
<span>Username</span>
<i></i>
</div>
<div class="input-box">
<input class="signup-password" type={move || if show_password() { "text" } else { "password"} } required style="width: 90%;"
on:input = move |ev| {
set_password(event_target_value(&ev));
log!("password changed to: {}", password.get());
}
/>
<span>Password</span>
<i></i>
<Show
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="password-visibility"> <Icon icon=Icon::from(AiEyeInvisibleFilled) /></button> /> }
>
<button on:click=toggle_password class="password-visibility">
<Icon icon=Icon::from(AiEyeFilled) />
</button>
</Show>
</div>
<input type="submit" value="Sign Up" />
<span class="go-to-login">
Already Have an Account? <a href="/login" class="link" >Go to Login</a>
</span>
</form>
</div>
</div>
}
}

View File

@ -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<Date>,
}
}
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<Int4>,
track -> Nullable<Int4>,
duration -> Int4,
release_date -> Nullable<Date>,
storage_path -> Varchar,
image_path -> Nullable<Varchar>,
}
}
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,
);

109
src/search.rs Normal file
View File

@ -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<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
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<Vec<Album>, 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<Vec<Artist>, 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<Vec<Song>, 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<Album>, Vec<Artist>, Vec<Song>), 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?))
}

104
src/users.rs Normal file
View File

@ -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<Option<User>, 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::<User>(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<Option<User>, 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<Option<User>, 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)
}

151
style/login.scss Normal file
View File

@ -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;
}

View File

@ -1,6 +1,8 @@
@import 'playbar.scss';
@import 'theme.scss';
@import 'queue.scss';
@import 'login.scss';
@import 'signup.scss';
body {
font-family: sans-serif;

151
style/signup.scss Normal file
View File

@ -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;
}

View File

@ -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;