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! {
+
+
+
+
+
LibreTunes
+
+
+
+
+ }
+}
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! {
+
+
+
+
+
LibreTunes
+
+
+
+
+ }
+}
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