From f66dc0c3d1431f93a00d752e729a67fc479697e6 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:13:26 -0700 Subject: [PATCH 1/4] Add account locking, update Rust, migrate session management to Prisma --- .github/actions/setup-cargo/action.yml | 2 +- .moon/toolchain.yml | 2 +- Cargo.lock | 635 ++++++++++-------- Cargo.toml | 8 +- apps/server/Cargo.toml | 10 +- apps/server/src/config/cors.rs | 2 +- apps/server/src/config/mod.rs | 1 + .../server/src/config/prisma_session_store.rs | 147 ++++ apps/server/src/config/session.rs | 78 ++- apps/server/src/errors.rs | 14 + apps/server/src/http_server.rs | 102 +++ apps/server/src/macros.rs | 20 - apps/server/src/main.rs | 123 +--- apps/server/src/middleware/auth.rs | 54 +- apps/server/src/routers/api/v1/auth.rs | 163 +++-- apps/server/src/routers/api/v1/epub.rs | 6 +- apps/server/src/routers/api/v1/filesystem.rs | 4 +- apps/server/src/routers/api/v1/library.rs | 14 +- apps/server/src/routers/api/v1/log.rs | 6 +- apps/server/src/routers/api/v1/media.rs | 26 +- .../server/src/routers/api/v1/reading_list.rs | 12 +- apps/server/src/routers/api/v1/series.rs | 16 +- apps/server/src/routers/api/v1/user.rs | 55 +- apps/server/src/routers/opds.rs | 13 +- apps/server/src/utils/auth.rs | 33 +- .../migration.sql | 22 + core/prisma/schema.prisma | 23 + core/src/db/entity/user.rs | 13 +- crates/cli/Cargo.toml | 20 + crates/cli/bin/main.rs | 22 + crates/cli/src/commands/account.rs | 156 +++++ crates/cli/src/commands/mod.rs | 47 ++ crates/cli/src/config.rs | 13 + crates/cli/src/error.rs | 13 + crates/cli/src/lib.rs | 37 + packages/client/src/queries/auth.ts | 33 +- .../src/scenes/auth/LoginOrClaimScene.tsx | 37 +- .../user/user-table/UserActionMenu.tsx | 14 +- .../settings/user/user-table/UserTable.tsx | 11 +- packages/types/core.ts | 4 +- scripts/release/Dockerfile.debian | 2 +- 41 files changed, 1360 insertions(+), 653 deletions(-) create mode 100644 apps/server/src/config/prisma_session_store.rs create mode 100644 apps/server/src/http_server.rs delete mode 100644 apps/server/src/macros.rs create mode 100644 core/prisma/migrations/20231002200152_account_locking/migration.sql create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/bin/main.rs create mode 100644 crates/cli/src/commands/account.rs create mode 100644 crates/cli/src/commands/mod.rs create mode 100644 crates/cli/src/config.rs create mode 100644 crates/cli/src/error.rs create mode 100644 crates/cli/src/lib.rs diff --git a/.github/actions/setup-cargo/action.yml b/.github/actions/setup-cargo/action.yml index e12d7b008..ce3416909 100644 --- a/.github/actions/setup-cargo/action.yml +++ b/.github/actions/setup-cargo/action.yml @@ -21,7 +21,7 @@ runs: - name: Install Rust uses: actions-rs/toolchain@v1 with: - toolchain: 1.68.0 + toolchain: 1.72.1 profile: minimal override: true components: rustfmt, clippy diff --git a/.moon/toolchain.yml b/.moon/toolchain.yml index 683e6b118..1457828d7 100644 --- a/.moon/toolchain.yml +++ b/.moon/toolchain.yml @@ -14,6 +14,6 @@ typescript: syncProjectReferencesToPaths: true rust: - version: '1.68.0' + version: '1.72.1' bins: - 'cargo-watch' diff --git a/Cargo.lock b/Cargo.lock index 31bf42875..f9d781f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cipher 0.4.4", "cpufeatures", ] @@ -100,59 +100,64 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.68" +name = "anstream" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" +checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] [[package]] -name = "arrayref" -version = "0.3.6" +name = "anstyle" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] -name = "arrayvec" -version = "0.5.2" +name = "anstyle-parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +dependencies = [ + "utf8parse", +] [[package]] -name = "ascii" -version = "0.9.3" +name = "anstyle-query" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] [[package]] -name = "async-lock" -version = "2.6.0" +name = "anstyle-wincon" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8101efe8695a6c17e02911402145357e718ac92d3ff88ae8419e84b1707b685" +checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" dependencies = [ - "event-listener", - "futures-lite", + "anstyle", + "windows-sys 0.48.0", ] [[package]] -name = "async-session" -version = "3.0.0" +name = "anyhow" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da4ce523b4e2ebaaf330746761df23a465b951a83d84bbce4233dabedae630" -dependencies = [ - "anyhow", - "async-lock", - "async-trait", - "base64 0.13.1", - "bincode", - "blake3", - "chrono", - "hmac 0.11.0", - "log", - "rand 0.8.5", - "serde", - "serde_json", - "sha2 0.9.9", -] +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" + +[[package]] +name = "ascii" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" [[package]] name = "async-stream" @@ -175,13 +180,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.61" +version = "0.1.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705339e0e4a9690e2908d2b3d049d85682cf19fbd5782494498fbf7003a6a282" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" dependencies = [ "proc-macro2", "quote 1.0.33", - "syn 1.0.107", + "syn 2.0.31", ] [[package]] @@ -289,9 +294,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f487e40dc9daee24d8a1779df88522f159a54a980f99cfbe43db0be0bd3444a8" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" dependencies = [ "async-trait", "bytes", @@ -312,7 +317,6 @@ checksum = "f9a320103719de37b7b4da4c8eb629d4573f6bcfd3dfe80d3208806895ccf81d" dependencies = [ "axum", "bytes", - "cookie", "futures-util", "http", "mime", @@ -338,22 +342,6 @@ dependencies = [ "syn 1.0.107", ] -[[package]] -name = "axum-sessions" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b114309d293dd8a6fedebf09d5b8bbb0f7647b3d204ca0dd333b5f797aed5c8" -dependencies = [ - "async-session", - "axum", - "axum-extra", - "futures", - "http-body", - "tokio", - "tower", - "tracing", -] - [[package]] name = "backtrace" version = "0.3.67" @@ -362,7 +350,7 @@ checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" dependencies = [ "addr2line", "cc", - "cfg-if 1.0.0", + "cfg-if", "libc", "miniz_oxide", "object", @@ -387,6 +375,12 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" +[[package]] +name = "base64" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" + [[package]] name = "bcrypt" version = "0.10.1" @@ -410,15 +404,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bindgen" version = "0.68.1" @@ -460,21 +445,6 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" -[[package]] -name = "blake3" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b64485778c4f16a6a5a9d335e80d449ac6c70cdd6a06d2af18a6f6f775a125b3" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if 0.1.10", - "constant_time_eq", - "crypto-mac 0.8.0", - "digest 0.9.0", -] - [[package]] name = "block" version = "0.1.6" @@ -705,12 +675,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.0" @@ -763,6 +727,60 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "clap" +version = "4.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d04704f56c2cde07f43e8e2c154b43f216dc5c92fc98ada720177362f953b956" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e231faeaca65ebd1ea3c737966bf858971cd38c3849107aa3ea7de90a804e45" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" +dependencies = [ + "heck 0.4.0", + "proc-macro2", + "quote 1.0.33", + "syn 2.0.31", +] + +[[package]] +name = "clap_lex" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" + +[[package]] +name = "cli" +version = "0.1.0" +dependencies = [ + "bcrypt", + "clap", + "dialoguer", + "indicatif", + "prisma-client-rust", + "stump_core", + "thiserror", + "tokio", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -810,6 +828,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "colored" version = "2.0.0" @@ -854,13 +878,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "console" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.45.0", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "wasm-bindgen", ] @@ -874,12 +911,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "convert_case" version = "0.4.0" @@ -903,17 +934,12 @@ dependencies = [ [[package]] name = "cookie" -version = "0.16.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" dependencies = [ - "base64 0.20.0", - "hmac 0.12.1", "percent-encoding", - "rand 0.8.5", - "sha2 0.10.6", - "subtle", - "time 0.3.17", + "time 0.3.29", "version_check", ] @@ -973,7 +999,7 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -982,7 +1008,7 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", ] @@ -992,7 +1018,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-epoch", "crossbeam-utils", ] @@ -1004,7 +1030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a" dependencies = [ "autocfg", - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", "memoffset 0.7.1", "scopeguard", @@ -1016,7 +1042,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "crossbeam-utils", ] @@ -1026,7 +1052,7 @@ version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -1045,26 +1071,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" -dependencies = [ - "generic-array", - "subtle", -] - -[[package]] -name = "crypto-mac" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" -dependencies = [ - "generic-array", - "subtle", -] - [[package]] name = "cssparser" version = "0.27.2" @@ -1255,6 +1261,15 @@ dependencies = [ "adler32", ] +[[package]] +name = "deranged" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +dependencies = [ + "serde", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -1278,6 +1293,19 @@ dependencies = [ "pest", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -1295,7 +1323,6 @@ checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" dependencies = [ "block-buffer 0.10.3", "crypto-common", - "subtle", ] [[package]] @@ -1322,7 +1349,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "dirs-sys-next", ] @@ -1381,7 +1408,7 @@ dependencies = [ "schema-ast", "serde", "serde_json", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -1390,7 +1417,7 @@ version = "0.1.0" source = "git+https://github.com/Brendonovich/prisma-engines?tag=pcr-0.6.10#7e31dbeb1087c55a71c141c07d87bb39bc3a4e38" dependencies = [ "bigdecimal", - "indexmap", + "indexmap 1.9.2", "prisma-models", "psl", "schema", @@ -1453,13 +1480,19 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -1501,6 +1534,12 @@ dependencies = [ "zip 0.6.3", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "erased-serde" version = "0.3.31" @@ -1510,12 +1549,6 @@ dependencies = [ "serde", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "exr" version = "1.5.2" @@ -1588,7 +1621,7 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e884668cd0c7480504233e951174ddc3b382f7c2666e3b7310b5c4e7b0c37f9" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "redox_syscall", "windows-sys 0.42.0", @@ -1732,21 +1765,6 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" -[[package]] -name = "futures-lite" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - [[package]] name = "futures-macro" version = "0.3.25" @@ -1904,7 +1922,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "wasi 0.9.0+wasi-snapshot-preview1", ] @@ -1915,7 +1933,7 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", @@ -2058,7 +2076,7 @@ version = "0.3.0" source = "git+https://github.com/prisma/graphql-parser#6a3f58bd879065588e710cb02b5bd30c1ce182c3" dependencies = [ "combine 3.8.1", - "indexmap", + "indexmap 1.9.2", "thiserror", ] @@ -2129,7 +2147,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap", + "indexmap 1.9.2", "slab", "tokio", "tokio-util", @@ -2163,6 +2181,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" + [[package]] name = "hashlink" version = "0.7.0" @@ -2174,12 +2198,11 @@ dependencies = [ [[package]] name = "headers" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" +checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ - "base64 0.13.1", - "bitflags 1.3.2", + "base64 0.21.4", "bytes", "headers-core", "http", @@ -2236,25 +2259,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" -dependencies = [ - "crypto-mac 0.11.1", - "digest 0.9.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.6", -] - [[package]] name = "hostname" version = "0.3.1" @@ -2282,9 +2286,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" dependencies = [ "bytes", "fnv", @@ -2480,6 +2484,29 @@ dependencies = [ "serde", ] +[[package]] +name = "indexmap" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" +dependencies = [ + "equivalent", + "hashbrown 0.14.1", +] + +[[package]] +name = "indicatif" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb28741c9db9a713d93deb3bb9515c20788cef5815265bee4980e87bde7e0f25" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + [[package]] name = "indoc" version = "1.0.8" @@ -2540,7 +2567,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -2781,7 +2808,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "winapi", ] @@ -2791,7 +2818,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d580318f95776505201b28cf98eb1fa5e4be3b689633ba6a3e6cd880ff22d8cb" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "windows-sys 0.48.0", ] @@ -2858,6 +2885,7 @@ checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -2866,7 +2894,7 @@ version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -2875,7 +2903,7 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "generator", "scoped-tls", "serde", @@ -2922,7 +2950,7 @@ dependencies = [ "dirs-next", "objc-foundation", "objc_id", - "time 0.3.17", + "time 0.3.29", ] [[package]] @@ -3047,7 +3075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953cbbb6f9ba4b9304f4df79b98cdc9d14071ed93065a9fca11c00c5d9181b66" dependencies = [ "hyper", - "indexmap", + "indexmap 1.9.2", "ipnet", "metrics 0.19.0", "metrics-util 0.13.0", @@ -3080,7 +3108,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.11.2", - "indexmap", + "indexmap 1.9.2", "metrics 0.18.1", "num_cpus", "ordered-float", @@ -3413,6 +3441,12 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc" version = "0.2.7" @@ -3490,7 +3524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b102428fd03bc5edf97f62620f7298614c45cedf287c271e7ed450bbaf83f2e1" dependencies = [ "bitflags 1.3.2", - "cfg-if 1.0.0", + "cfg-if", "foreign-types", "libc", "once_cell", @@ -3635,12 +3669,6 @@ dependencies = [ "system-deps 6.0.3", ] -[[package]] -name = "parking" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" - [[package]] name = "parking_lot" version = "0.11.2" @@ -3668,7 +3696,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "instant", "libc", "redox_syscall", @@ -3682,7 +3710,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -3697,7 +3725,7 @@ dependencies = [ "diagnostics", "either", "enumflags2", - "indexmap", + "indexmap 1.9.2", "schema-ast", ] @@ -3993,10 +4021,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225" dependencies = [ "base64 0.13.1", - "indexmap", + "indexmap 1.9.2", "line-wrap", "serde", - "time 0.3.17", + "time 0.3.29", "xml-rs", ] @@ -4012,6 +4040,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31114a898e107c51bb1609ffaf55a0e011cf6a4d7f1170d0015a165082c0338b" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -4055,7 +4089,7 @@ dependencies = [ "dotenv", "futures", "include_dir", - "indexmap", + "indexmap 1.9.2", "migration-core", "paste", "prisma-client-rust-macros", @@ -4072,7 +4106,7 @@ dependencies = [ "tokio", "tracing", "user-facing-errors", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -4155,7 +4189,7 @@ dependencies = [ "regex", "serde", "serde_json", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -4265,7 +4299,7 @@ dependencies = [ "tracing", "tracing-core", "url", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -4293,7 +4327,7 @@ dependencies = [ "async-trait", "chrono", "futures", - "indexmap", + "indexmap 1.9.2", "itertools", "prisma-models", "prisma-value", @@ -4301,7 +4335,7 @@ dependencies = [ "serde_json", "thiserror", "user-facing-errors", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -4318,7 +4352,7 @@ dependencies = [ "cuid", "enumflags2", "futures", - "indexmap", + "indexmap 1.9.2", "itertools", "lazy_static", "lru", @@ -4345,7 +4379,7 @@ dependencies = [ "tracing-subscriber", "url", "user-facing-errors", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -4640,7 +4674,7 @@ dependencies = [ "dmmf", "futures", "graphql-parser", - "indexmap", + "indexmap 1.9.2", "itertools", "psl", "query-core", @@ -5031,7 +5065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d051fb33111db0e81673ed8c55db741952a19ad81dc584960c8aec836498ba5" dependencies = [ "form_urlencoded", - "indexmap", + "indexmap 1.9.2", "itoa 1.0.5", "ryu", "serde", @@ -5039,11 +5073,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" dependencies = [ - "indexmap", + "indexmap 2.0.2", "itoa 1.0.5", "ryu", "serde", @@ -5154,7 +5188,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest 0.10.6", ] @@ -5166,7 +5200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest 0.9.0", "opaque-debug", @@ -5178,7 +5212,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "cpufeatures", "digest 0.10.6", ] @@ -5202,6 +5236,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shellexpand" version = "2.1.2" @@ -5411,7 +5451,7 @@ dependencies = [ "tracing-futures", "url", "user-facing-errors", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -5442,7 +5482,7 @@ dependencies = [ "tracing-futures", "tracing-opentelemetry", "user-facing-errors", - "uuid 1.4.0", + "uuid 1.4.1", ] [[package]] @@ -5453,7 +5493,7 @@ dependencies = [ "async-trait", "bigdecimal", "enumflags2", - "indexmap", + "indexmap 1.9.2", "indoc", "once_cell", "psl", @@ -5591,7 +5631,7 @@ dependencies = [ "unrar", "urlencoding", "utoipa", - "uuid 1.4.0", + "uuid 1.4.1", "walkdir", "webp", "xml-rs", @@ -5618,10 +5658,10 @@ dependencies = [ "axum", "axum-extra", "axum-macros", - "axum-sessions", "base64 0.13.1", "bcrypt", "chrono", + "cli", "futures-util", "hyper", "local-ip-address", @@ -5636,21 +5676,18 @@ dependencies = [ "specta", "stump_core", "thiserror", + "time 0.3.29", "tokio", "tokio-util", + "tower", "tower-http", + "tower-sessions", "tracing", "urlencoding", "utoipa", "utoipa-swagger-ui", ] -[[package]] -name = "subtle" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" - [[package]] name = "syn" version = "0.11.11" @@ -5765,7 +5802,7 @@ dependencies = [ "scopeguard", "serde", "unicode-segmentation", - "uuid 1.4.0", + "uuid 1.4.1", "windows 0.39.0", "windows-implement", "x11-dl", @@ -5829,7 +5866,7 @@ dependencies = [ "thiserror", "tokio", "url", - "uuid 1.4.0", + "uuid 1.4.1", "webkit2gtk", "webview2-com", "windows 0.39.0", @@ -5872,8 +5909,8 @@ dependencies = [ "sha2 0.10.6", "tauri-utils", "thiserror", - "time 0.3.17", - "uuid 1.4.0", + "time 0.3.29", + "uuid 1.4.1", "walkdir", ] @@ -5906,7 +5943,7 @@ dependencies = [ "serde_json", "tauri-utils", "thiserror", - "uuid 1.4.0", + "uuid 1.4.1", "webview2-com", "windows 0.39.0", ] @@ -5924,7 +5961,7 @@ dependencies = [ "raw-window-handle", "tauri-runtime", "tauri-utils", - "uuid 1.4.0", + "uuid 1.4.1", "webkit2gtk", "webview2-com", "windows 0.39.0", @@ -5986,7 +6023,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "fastrand", "libc", "redox_syscall", @@ -6022,22 +6059,22 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" [[package]] name = "thiserror" -version = "1.0.38" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" +checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.38" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" +checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2", "quote 1.0.33", - "syn 1.0.107", + "syn 2.0.31", ] [[package]] @@ -6081,10 +6118,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.17" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" +checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe" dependencies = [ + "deranged", "itoa 1.0.5", "serde", "time-core", @@ -6093,15 +6131,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.6" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" dependencies = [ "time-core", ] @@ -6213,6 +6251,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40f38d941a2ffd8402b36e02ae407637a9caceb693aaf2edc910437db0f36984" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot 0.12.1", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.3.5" @@ -6251,13 +6306,33 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "tower-sessions" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b51233895d1173cae657a2ce44bd414efe20ae0971216f8ddf6ca528498a20c" +dependencies = [ + "async-trait", + "axum-core", + "http", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror", + "time 0.3.29", + "tower-cookies", + "tower-layer", + "tower-service", + "uuid 1.4.1", +] + [[package]] name = "tracing" version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "log", "pin-project-lite", "tracing-attributes", @@ -6271,7 +6346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d48f71a791638519505cefafe162606f706c25592e4bde4d97600c0195312e" dependencies = [ "crossbeam-channel", - "time 0.3.17", + "time 0.3.29", "tracing-subscriber", ] @@ -6569,13 +6644,19 @@ dependencies = [ "byteorder", ] +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "utoipa" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15f6da6a2b471134ca44b7d18e8a76d73035cf8b3ed24c4dd5ca6a63aa439c5" dependencies = [ - "indexmap", + "indexmap 1.9.2", "serde", "serde_json", "utoipa-gen", @@ -6620,9 +6701,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" +checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom 0.2.8", "serde", @@ -6673,12 +6754,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" -[[package]] -name = "waker-fn" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" - [[package]] name = "walkdir" version = "2.3.2" @@ -6724,7 +6799,7 @@ version = "0.2.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "wasm-bindgen-macro", ] @@ -6749,7 +6824,7 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb6ec270a31b1d3c7e266b999739109abce8b6c87e4b31fcfcd788b65267395" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "js-sys", "wasm-bindgen", "web-sys", @@ -7011,13 +7086,37 @@ dependencies = [ "windows_x86_64_msvc 0.42.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.1", +] + [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.1", + "windows_aarch64_msvc 0.42.1", + "windows_i686_gnu 0.42.1", + "windows_i686_msvc 0.42.1", + "windows_x86_64_gnu 0.42.1", + "windows_x86_64_gnullvm 0.42.1", + "windows_x86_64_msvc 0.42.1", ] [[package]] @@ -7277,6 +7376,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" + [[package]] name = "zip" version = "0.5.13" diff --git a/Cargo.toml b/Cargo.toml index 1bdb57bfe..4313001d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,12 @@ members = [ "apps/desktop/src-tauri", "apps/server", "core", - # "core/integration-tests", - "crates/prisma-cli", + "crates/*", ] [workspace.package] version = "0.0.0" -rust-version = "1.68.0" +rust-version = "1.72.1" # TODO: replace my fork with next official pcr release when it comes out, I just don't # have the time for undocumented breaking changes right now @@ -44,6 +43,9 @@ urlencoding = "2.1.2" ### DEV UTILS ### specta = "1.0.2" +### AUTH ### +bcrypt = "0.10.1" + ### Error Handling + Logging ### tracing = "0.1.36" thiserror = "1.0.37" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index b8d73ffb3..c35d22f45 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -6,8 +6,9 @@ default-run = "stump_server" [dependencies] stump_core = { path = "../../core" } +cli = { path = "../../crates/cli" } prisma-client-rust = { workspace = true } -axum = { version = "0.6.1", features = ["ws"] } +axum = { version = "0.6.1", features = ["ws", "headers"] } axum-macros = "0.3.0" axum-extra = { version = "0.4.2", features = [ "spa", @@ -26,7 +27,8 @@ futures-util = "0.3.24" tokio = { workspace = true } tokio-util = "0.7.4" serde = { workspace = true } -axum-sessions = "0.4.1" +# axum-sessions = "0.4.1" +tower-sessions = "0.2.1" async-trait = "0.1.53" async-stream = { workspace = true } local-ip-address = { git = "https://github.com/EstebanBorai/local-ip-address.git", tag = "v0.5.1" } @@ -47,8 +49,10 @@ tracing = { workspace = true } thiserror = { workspace = true } ### Auth ### -bcrypt = "0.10.1" +bcrypt = { workspace = true } base64 = "0.13.0" +time = "0.3.29" +tower = "0.4.13" ### Platform Specific Deps ### [target.aarch64-unknown-linux-musl.dependencies] diff --git a/apps/server/src/config/cors.rs b/apps/server/src/config/cors.rs index e0aea6ca1..127ce1d1a 100644 --- a/apps/server/src/config/cors.rs +++ b/apps/server/src/config/cors.rs @@ -23,7 +23,7 @@ fn merge_origins(origins: &[&str], local_origins: Vec) -> Vec>() diff --git a/apps/server/src/config/mod.rs b/apps/server/src/config/mod.rs index 77e04bf3e..e7102dc9f 100644 --- a/apps/server/src/config/mod.rs +++ b/apps/server/src/config/mod.rs @@ -1,4 +1,5 @@ pub mod cors; +mod prisma_session_store; pub mod session; pub mod state; pub mod utils; diff --git a/apps/server/src/config/prisma_session_store.rs b/apps/server/src/config/prisma_session_store.rs new file mode 100644 index 000000000..f66d844cb --- /dev/null +++ b/apps/server/src/config/prisma_session_store.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; + +use prisma_client_rust::{ + chrono::{DateTime, Duration, FixedOffset, Utc}, + QueryError, +}; +use stump_core::{ + db::entity::User, + prisma::{session, user, PrismaClient}, +}; +use time::OffsetDateTime; +use tower_sessions::{session::SessionId, Session, SessionRecord, SessionStore}; + +use super::session::{get_session_ttl, SESSION_USER_KEY}; + +#[derive(Clone)] +pub struct PrismaSessionStore { + client: Arc, +} + +impl PrismaSessionStore { + pub fn new(client: Arc) -> Self { + Self { client } + } + + async fn delete_expired(&self) -> Result<(), QueryError> { + tracing::trace!("Deleting expired sessions"); + + let affected_rows = self + .client + .session() + .delete_many(vec![session::expires_at::lt(Utc::now().into())]) + .exec() + .await?; + + tracing::trace!(affected_rows = ?affected_rows, "Deleted expired sessions"); + + Ok(()) + } + + pub async fn continuously_delete_expired(self, period: tokio::time::Duration) { + let mut interval = tokio::time::interval(period); + loop { + if let Err(error) = self.delete_expired().await { + tracing::error!(error = ?error, "Failed to delete expired sessions"); + } + interval.tick().await; + } + } +} + +// FIXME: There are a LOT of expectations here. Looking at the source code for other implementations, +// e.g. sqlx sqlite store, this was also the case. If possible, though, I'd like to more safely +// handle these cases. + +#[async_trait::async_trait] +impl SessionStore for PrismaSessionStore { + type Error = QueryError; + + async fn save(&self, session_record: &SessionRecord) -> Result<(), Self::Error> { + let expires_at: DateTime = + (Utc::now() + Duration::seconds(get_session_ttl())).into(); + let session_user = session_record + .data() + .get(SESSION_USER_KEY) + .cloned() + .expect("Failed to get user from session"); + let user = serde_json::from_value::(session_user.clone()) + .expect("Failed to deserialize user"); + let session_id = session_record.id().to_string(); + let session_data = serde_json::to_vec(&session_record.data()) + .expect("Failed to serialize session data"); + + tracing::trace!(session_id, ?user, "Saving session"); + + let result = self + .client + .session() + .upsert( + session::id::equals(session_id.clone()), + ( + expires_at, + session_data.clone(), + user::id::equals(user.id.clone()), + vec![session::id::set(session_id)], + ), + vec![ + session::user::connect(user::id::equals(user.id)), + session::expires_at::set(expires_at), + session::data::set(session_data), + ], + ) + .exec() + .await?; + + tracing::trace!(result = ?result, "Upserted session"); + + Ok(()) + } + + async fn load(&self, session_id: &SessionId) -> Result, Self::Error> { + tracing::trace!(?session_id, "Loading session"); + + let record = self + .client + .session() + .find_first(vec![ + session::id::equals(session_id.to_string()), + session::expires_at::gt(Utc::now().into()), + ]) + .exec() + .await?; + + if let Some(result) = record { + tracing::trace!(result = ?result, "Found session"); + + let timestamp = result.expires_at.timestamp(); + let expiration_time = OffsetDateTime::from_unix_timestamp(timestamp) + .expect("Failed to convert timestamp to OffsetDateTime"); + let session_record = SessionRecord::new( + session_id.to_owned(), + Some(expiration_time), + serde_json::from_slice(&result.data) + .expect("Failed to deserialize session data"), + ); + Ok(Some(session_record.into())) + } else { + tracing::trace!(?session_id, "No session found"); + Ok(None) + } + } + + async fn delete(&self, session_id: &SessionId) -> Result<(), Self::Error> { + tracing::trace!(session_id = ?session_id, "Deleting session"); + + let removed_session = self + .client + .session() + .delete(session::id::equals(session_id.to_string())) + .exec() + .await?; + + tracing::trace!(removed_session = ?removed_session, "Removed session"); + + Ok(()) + } +} diff --git a/apps/server/src/config/session.rs b/apps/server/src/config/session.rs index 608a429f1..d9bdcd41f 100644 --- a/apps/server/src/config/session.rs +++ b/apps/server/src/config/session.rs @@ -1,50 +1,54 @@ -use std::{env, time::Duration}; +use std::{env, sync::Arc}; +use stump_core::prisma::PrismaClient; +use time::Duration; -use axum_sessions::{async_session::MemoryStore, SameSite, SessionLayer}; -use rand::{thread_rng, Rng}; +use tower_sessions::{cookie::SameSite, SessionManagerLayer}; -fn rand_secret() -> Vec { - let mut rng = thread_rng(); - let mut arr = [0u8; 128]; - rng.fill(&mut arr); - arr.to_vec() -} - -pub fn get_session_layer() -> SessionLayer { - let store = MemoryStore::new(); +use super::prisma_session_store::PrismaSessionStore; - let secret = env::var("SESSION_SECRET") - .map(|s| s.into_bytes()) - .unwrap_or_else(|_| rand_secret()); +pub(crate) const SESSION_USER_KEY: &str = "user"; - let ttl = env::var("SESSION_TTL") +pub(crate) fn get_session_ttl() -> i64 { + env::var("SESSION_TTL") .map(|s| { - s.parse::().unwrap_or_else(|e| { + s.parse::().unwrap_or_else(|e| { tracing::error!(error = ?e, "Failed to parse provided SESSION_TTL"); 3600 * 24 * 3 }) }) - .unwrap_or(3600 * 24 * 3); + .unwrap_or(3600 * 24 * 3) +} - let sesssion_layer = SessionLayer::new(store, &secret) - .with_cookie_name("stump_session") - .with_session_ttl(Some(Duration::from_secs(ttl))) - .with_cookie_path("/"); +pub(crate) fn get_session_expiry_cleanup_interval() -> u64 { + env::var("SESSION_EXPIRY_CLEANUP_INTERVAL") + .map(|s| { + s.parse::().unwrap_or_else(|e| { + tracing::error!(error = ?e, "Failed to parse provided SESSION_EXPIRY_CLEANUP_INTERVAL"); + 60 + }) + }) + .unwrap_or(60) +} - sesssion_layer - .with_same_site_policy(SameSite::Lax) +pub fn get_session_layer( + client: Arc, +) -> SessionManagerLayer { + let store = PrismaSessionStore::new(client); + + let cleanup_interval = get_session_expiry_cleanup_interval(); + tokio::task::spawn( + store + .clone() + .continuously_delete_expired(tokio::time::Duration::from_secs( + cleanup_interval, + )), + ); + let session_ttl = get_session_ttl(); + + SessionManagerLayer::new(store) + .with_name("stump_session") + .with_max_age(Duration::seconds(session_ttl)) + .with_path("/".to_string()) + .with_same_site(SameSite::Lax) .with_secure(false) - - // TODO: I think this can be configurable, but most people are going to be insecurely - // running this, which means `secure` needs to be false otherwise the cookie won't - // be sent. - // if env::var("STUMP_PROFILE").unwrap_or_else(|_| "release".into()) == "release" { - // sesssion_layer - // .with_same_site_policy(SameSite::None) - // .with_secure(true) - // } else { - // sesssion_layer - // .with_same_site_policy(SameSite::Lax) - // .with_secure(false) - // } } diff --git a/apps/server/src/errors.rs b/apps/server/src/errors.rs index 9c4b619cd..f3629cd91 100644 --- a/apps/server/src/errors.rs +++ b/apps/server/src/errors.rs @@ -2,6 +2,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use cli::CliError; use prisma_client_rust::{ prisma_errors::query_engine::{RecordNotFound, UniqueKeyViolation}, QueryError, @@ -11,6 +12,7 @@ use stump_core::{ job::JobManagerError, }; use tokio::sync::mpsc; +use tower_sessions::session::SessionError; use utoipa::ToSchema; use std::net; @@ -19,6 +21,16 @@ use thiserror::Error; pub type ServerResult = Result; pub type ApiResult = Result; +#[derive(Debug, Error)] +pub enum EntryError { + #[error("{0}")] + InvalidConfig(String), + #[error("{0}")] + CliError(#[from] CliError), + #[error("{0}")] + ServerError(#[from] ServerError), +} + #[derive(Debug, Error)] pub enum ServerError { // TODO: meh @@ -97,6 +109,8 @@ pub enum ApiError { #[error("{0}")] Redirect(String), #[error("{0}")] + SessionFetchError(#[from] SessionError), + #[error("{0}")] #[schema(value_type = String)] PrismaError(#[from] QueryError), } diff --git a/apps/server/src/http_server.rs b/apps/server/src/http_server.rs new file mode 100644 index 000000000..233de30ce --- /dev/null +++ b/apps/server/src/http_server.rs @@ -0,0 +1,102 @@ +use std::net::SocketAddr; + +use axum::{error_handling::HandleErrorLayer, extract::connect_info::Connected, Router}; +use hyper::{server::conn::AddrStream, StatusCode}; +use stump_core::{event::InternalCoreTask, StumpCore}; +use tokio::sync::oneshot; +use tower::{BoxError, ServiceBuilder}; +use tower_http::trace::TraceLayer; + +use crate::{ + config::{cors, session}, + errors::{ServerError, ServerResult}, + routers, + utils::shutdown_signal_with_cleanup, +}; + +pub(crate) async fn run_http_server(port: u16) -> ServerResult<()> { + let core = StumpCore::new().await; + if let Err(err) = core.run_migrations().await { + tracing::error!("Failed to run migrations: {:?}", err); + return Err(ServerError::ServerStartError(err.to_string())); + } + + // Initialize the server configuration. If it already exists, nothing will happen. + core.init_server_config() + .await + .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + + // Initialize the job manager + core.get_job_manager() + .init() + .await + .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + + // Initialize the scheduler + core.init_scheduler() + .await + .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + + let server_ctx = core.get_context(); + let app_state = server_ctx.arced(); + let cors_layer = cors::get_cors_layer(port); + + tracing::info!("{}", core.get_shadow_text()); + + let session_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|err: BoxError| async move { + tracing::error!("Failed to handle session: {:?}", err); + StatusCode::BAD_REQUEST + })) + .layer(session::get_session_layer(app_state.db.clone())); + + let app = Router::new() + .merge(routers::mount(app_state.clone())) + .with_state(app_state.clone()) + .layer(session_service) + .layer(cors_layer) + // TODO: not sure if it needs to be done in here or stump_core::config::logging, + // but I want to ignore traces for asset requests, e.g. /assets/chunk-SRMZVY4F.02115dd3.js lol + .layer(TraceLayer::new_for_http()); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("⚡️ Stump HTTP server starting on http://{}", addr); + + // TODO: might need to refactor to use https://docs.rs/async-shutdown/latest/async_shutdown/ + let cleanup = || async move { + println!("Initializing graceful shutdown..."); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let _ = core + .get_context() + .dispatch_task(InternalCoreTask::Shutdown { + return_sender: shutdown_tx, + }); + + shutdown_rx + .await + .expect("Failed to successfully handle shutdown"); + }; + + axum::Server::bind(&addr) + .serve(app.into_make_service_with_connect_info::()) + .with_graceful_shutdown(shutdown_signal_with_cleanup(Some(cleanup))) + .await + .expect("Failed to start Stump HTTP server!"); + + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct StumpRequestInfo { + pub ip_addr: std::net::IpAddr, +} + +impl Connected<&AddrStream> for StumpRequestInfo { + fn connect_info(target: &AddrStream) -> Self { + StumpRequestInfo { + ip_addr: target.remote_addr().ip(), + } + } +} diff --git a/apps/server/src/macros.rs b/apps/server/src/macros.rs deleted file mode 100644 index 5ad2b9d3c..000000000 --- a/apps/server/src/macros.rs +++ /dev/null @@ -1,20 +0,0 @@ -// #[macro_export] -// macro_rules! prisma_joiner { -// ($name:expr) => { -// macro_rules! joiner { -// ($($x:expr),+ $(,)?) => { -// $crate::operator::$name(vec![$($x),+]) -// }; -// } -// }; -// } -// #[macro_export] -// macro_rules! prisma_joiner { -// ($name:ident) => { -// macro_rules! $name { -// ($($x:expr),* $(,)?) => { -// $crate::operator::$name(vec![$($x),*]) -// }; -// } -// }; -// } diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 885211f73..df5682049 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,23 +1,14 @@ -use std::net::SocketAddr; - -use axum::{extract::connect_info::Connected, Router}; -use errors::{ServerError, ServerResult}; -use hyper::server::conn::AddrStream; -use stump_core::{config::logging::init_tracing, event::InternalCoreTask, StumpCore}; -use tokio::sync::oneshot; -use tower_http::trace::TraceLayer; -use tracing::{error, info, trace}; +use cli::{handle_command, Cli, Parser}; +use errors::EntryError; +use stump_core::{config::logging::init_tracing, StumpCore}; mod config; mod errors; -mod macros; +mod http_server; mod middleware; mod routers; mod utils; -use config::{cors, session}; -use utils::shutdown_signal_with_cleanup; - fn debug_setup() { std::env::set_var( "STUMP_CLIENT_DIR", @@ -26,102 +17,28 @@ fn debug_setup() { std::env::set_var("STUMP_PROFILE", "debug"); } -#[derive(Clone, Debug)] -pub struct StumpRequestInfo { - pub ip_addr: std::net::IpAddr, -} - -impl Connected<&AddrStream> for StumpRequestInfo { - fn connect_info(target: &AddrStream) -> Self { - StumpRequestInfo { - ip_addr: target.remote_addr().ip(), - } - } -} - #[tokio::main(flavor = "multi_thread")] -async fn main() -> ServerResult<()> { +async fn main() -> Result<(), EntryError> { #[cfg(debug_assertions)] debug_setup(); - let stump_environment = StumpCore::init_environment(); - if let Err(err) = stump_environment { - error!("Failed to load environment variables: {:?}", err); - return Err(ServerError::ServerStartError(err.to_string())); - } - let stump_environment = stump_environment.unwrap(); - let port = stump_environment.port.unwrap_or(10801); - - // Note: init_tracing after loading the environment so the correct verbosity - // level is used for logging. - init_tracing(); + let config = StumpCore::init_environment() + .map_err(|e| EntryError::InvalidConfig(e.to_string()))?; - if stump_environment.verbosity.unwrap_or(1) >= 3 { - trace!("Environment configuration: {:?}", stump_environment); - } - - let core = StumpCore::new().await; - if let Err(err) = core.run_migrations().await { - error!("Failed to run migrations: {:?}", err); - return Err(ServerError::ServerStartError(err.to_string())); - } + let cli = Cli::parse(); - // Initialize the server configuration. If it already exists, nothing will happen. - core.init_server_config() - .await - .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + if let Some(command) = cli.command { + Ok(handle_command(command, cli.config).await?) + } else { + let port = config.port.unwrap_or(10801); + // Note: init_tracing after loading the environment so the correct verbosity + // level is used for logging. + init_tracing(); - // Initialize the job manager - core.get_job_manager() - .init() - .await - .map_err(|e| ServerError::ServerStartError(e.to_string()))?; - - // Initialize the scheduler - core.init_scheduler() - .await - .map_err(|e| ServerError::ServerStartError(e.to_string()))?; - - let server_ctx = core.get_context(); - let app_state = server_ctx.arced(); - let cors_layer = cors::get_cors_layer(port); - - info!("{}", core.get_shadow_text()); - - let app = Router::new() - .merge(routers::mount(app_state.clone())) - .with_state(app_state.clone()) - .layer(session::get_session_layer()) - .layer(cors_layer) - // TODO: not sure if it needs to be done in here or stump_core::config::logging, - // but I want to ignore traces for asset requests, e.g. /assets/chunk-SRMZVY4F.02115dd3.js lol - .layer(TraceLayer::new_for_http()); - - let addr = SocketAddr::from(([0, 0, 0, 0], port)); - info!("⚡️ Stump HTTP server starting on http://{}", addr); - - // TODO: might need to refactor to use https://docs.rs/async-shutdown/latest/async_shutdown/ - let cleanup = || async move { - println!("Initializing graceful shutdown..."); - - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - - let _ = core - .get_context() - .dispatch_task(InternalCoreTask::Shutdown { - return_sender: shutdown_tx, - }); - - shutdown_rx - .await - .expect("Failed to successfully handle shutdown"); - }; - - axum::Server::bind(&addr) - .serve(app.into_make_service_with_connect_info::()) - .with_graceful_shutdown(shutdown_signal_with_cleanup(Some(cleanup))) - .await - .expect("Failed to start Stump HTTP server!"); + if config.verbosity.unwrap_or(1) >= 3 { + tracing::trace!(?config, "App config"); + } - Ok(()) + Ok(http_server::run_http_server(port).await?) + } } diff --git a/apps/server/src/middleware/auth.rs b/apps/server/src/middleware/auth.rs index 8b37fbbc7..9cd04e3d1 100644 --- a/apps/server/src/middleware/auth.rs +++ b/apps/server/src/middleware/auth.rs @@ -5,16 +5,16 @@ use axum::{ http::{header, request::Parts, Method, StatusCode}, response::{IntoResponse, Redirect, Response}, }; -use axum_sessions::SessionHandle; use prisma_client_rust::{ prisma_errors::query_engine::{RecordNotFound, UniqueKeyViolation}, QueryError, }; use stump_core::{db::entity::User, prisma::user}; +use tower_sessions::Session; use tracing::{error, trace}; use crate::{ - config::state::AppState, + config::{session::SESSION_USER_KEY, state::AppState}, utils::{decode_base64_credentials, verify_password}, }; @@ -39,24 +39,29 @@ where } let state = AppState::from_ref(state); - let session_handle = - parts.extensions.get::().ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to extract session handle", - ) - .into_response() + // let session = parts.extensions.get::().ok_or_else(|| { + // ( + // StatusCode::INTERNAL_SERVER_ERROR, + // "Failed to extract session handle", + // ) + // .into_response() + // })?; + let session = Session::from_request_parts(parts, &state) + .await + .map_err(|e| { + error!("Failed to extract session handle: {}", e.1); + (StatusCode::INTERNAL_SERVER_ERROR).into_response() })?; - let session = session_handle.read().await; - if let Some(user) = session.get::("user") { + let session_user = session.get::(SESSION_USER_KEY).map_err(|e| { + tracing::error!(error = ?e, "Failed to get user from session"); + (StatusCode::INTERNAL_SERVER_ERROR).into_response() + })?; + if let Some(user) = session_user { trace!("Session for {} already exists", &user.username); return Ok(Self); } - // drop so we don't deadlock when writing to the session lol oy vey - drop(session); - let auth_header = parts .headers .get(header::AUTHORIZATION) @@ -119,12 +124,8 @@ where username = &user.username, "Basic authentication sucessful. Creating session for user" ); - // TODO: why did I use user as key here? I need to revisit the docs for this library because - // that doesn't seem right. - session_handle - .write() - .await - .insert("user", user.clone()) + session + .insert(SESSION_USER_KEY, user.clone()) .map_err(|e| { error!("Failed to insert user into session: {}", e); (StatusCode::INTERNAL_SERVER_ERROR).into_response() @@ -166,10 +167,16 @@ where return Ok(Self); } - let session_handle = parts.extensions.get::().unwrap(); - let session = session_handle.read().await; + let session = parts + .extensions + .get::() + .expect("Failed to extract session"); - if let Some(user) = session.get::("user") { + let session_user = session.get::(SESSION_USER_KEY).map_err(|e| { + tracing::error!(error = ?e, "Failed to get user from session"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + if let Some(user) = session_user { if user.is_admin() { return Ok(Self); } @@ -177,7 +184,6 @@ where return Err(StatusCode::FORBIDDEN); } - drop(session); return Err(StatusCode::UNAUTHORIZED); } } diff --git a/apps/server/src/routers/api/v1/auth.rs b/apps/server/src/routers/api/v1/auth.rs index f6857064c..dd92adfca 100644 --- a/apps/server/src/routers/api/v1/auth.rs +++ b/apps/server/src/routers/api/v1/auth.rs @@ -4,22 +4,25 @@ use axum::{ routing::{get, post}, Json, Router, TypedHeader, }; -use axum_sessions::extractors::{ReadableSession, WritableSession}; -use prisma_client_rust::chrono::Utc; +use prisma_client_rust::{ + chrono::{DateTime, Duration, FixedOffset, Utc}, + Direction, +}; use serde::Deserialize; use specta::Type; use stump_core::{ db::entity::{User, UserRole}, prisma::{user, user_login_activity, user_preferences, PrismaClient}, }; +use tower_sessions::{session::SessionDeletion, Session}; use tracing::error; use utoipa::ToSchema; use crate::{ - config::state::AppState, + config::{session::SESSION_USER_KEY, state::AppState}, errors::{ApiError, ApiResult}, + http_server::StumpRequestInfo, utils::{self, verify_password}, - StumpRequestInfo, }; pub(crate) fn mount() -> Router { @@ -50,8 +53,8 @@ pub struct LoginOrRegisterArgs { )] /// Returns the currently logged in user from the session. If no user is logged in, returns an /// unauthorized error. -async fn viewer(session: ReadableSession) -> ApiResult> { - if let Some(user) = session.get::("user") { +async fn viewer(session: Session) -> ApiResult> { + if let Some(user) = session.get::(SESSION_USER_KEY)? { Ok(Json(user)) } else { Err(ApiError::Unauthorized) @@ -95,18 +98,22 @@ async fn handle_login_attempt( async fn login( TypedHeader(user_agent): TypedHeader, ConnectInfo(request_info): ConnectInfo, - mut session: WritableSession, + session: Session, State(state): State, Json(input): Json, ) -> ApiResult> { - if let Some(user) = session.get::("user") { + if let Some(user) = session.get::(SESSION_USER_KEY)? { if input.username == user.username { return Ok(Json(user)); } } - let fetched_user = state - .db + let client = state.db.clone(); + let today: DateTime = Utc::now().into(); + // TODO: make this configuration option via environment variable + let twenty_four_hours_ago = today - Duration::hours(24); + + let fetch_result = client .user() .find_first(vec![ user::username::equals(input.username.to_owned()), @@ -114,57 +121,95 @@ async fn login( ]) .with(user::user_preferences::fetch()) .with(user::age_restriction::fetch()) + .with( + user::login_activity::fetch(vec![ + user_login_activity::timestamp::gte(twenty_four_hours_ago), + user_login_activity::timestamp::lte(today), + ]) + .order_by(user_login_activity::timestamp::order(Direction::Desc)) + .take(10), + ) .exec() .await?; - if let Some(db_user) = fetched_user { - let matches = verify_password(&db_user.hashed_password, &input.password)?; - if !matches { - handle_login_attempt(&state.db, db_user, user_agent, request_info, false) - .await?; - return Err(ApiError::Unauthorized); - } + match fetch_result { + Some(db_user) + if db_user.is_locked + && verify_password(&db_user.hashed_password, &input.password)? => + { + Err(ApiError::Forbidden( + "Account is locked. Please contact an administrator to unlock your account." + .to_string(), + )) + }, + Some(db_user) if !db_user.is_locked => { + let user_id = db_user.id.clone(); + let matches = verify_password(&db_user.hashed_password, &input.password)?; + if !matches { + let should_lock_account = db_user + .login_activity + .as_ref() + .map(|activity| activity.len() >= 9) + .unwrap_or(false); - let updated_user = state - .db - .user() - .update( - user::id::equals(db_user.id.clone()), - vec![user::last_login::set(Some(Utc::now().into()))], - ) - .with(user::user_preferences::fetch()) - .with(user::age_restriction::fetch()) - .exec() - .await - .unwrap_or_else(|err| { - error!(error = ?err, "Failed to update user last login!"); - user::Data { - last_login: Some(Utc::now().into()), - ..db_user + handle_login_attempt(&client, db_user, user_agent, request_info, false) + .await?; + + if should_lock_account { + client + .user() + .update( + user::id::equals(user_id), + vec![user::is_locked::set(true)], + ) + .exec() + .await?; } - }); - let login_track_result = handle_login_attempt( - &state.db, - updated_user.clone(), - user_agent, - request_info, - true, - ) - .await; - // I don't want to kill the login here, so not bubbling up the error - if let Err(err) = login_track_result { - error!(error = ?err, "Failed to track login attempt!"); - } - let user = User::from(updated_user); - session - .insert("user", user.clone()) - .expect("Failed to write user to session"); + return Err(ApiError::Unauthorized); + } - return Ok(Json(user)); - } + let updated_user = state + .db + .user() + .update( + user::id::equals(db_user.id.clone()), + vec![user::last_login::set(Some(Utc::now().into()))], + ) + .with(user::user_preferences::fetch()) + .with(user::age_restriction::fetch()) + .exec() + .await + .unwrap_or_else(|err| { + error!(error = ?err, "Failed to update user last login!"); + user::Data { + last_login: Some(Utc::now().into()), + ..db_user + } + }); + + let login_track_result = handle_login_attempt( + &state.db, + updated_user.clone(), + user_agent, + request_info, + true, + ) + .await; + // I don't want to kill the login here, so not bubbling up the error + if let Err(err) = login_track_result { + error!(error = ?err, "Failed to track login attempt!"); + } + + let user = User::from(updated_user); + session + .insert(SESSION_USER_KEY, user.clone()) + .expect("Failed to write user to session"); - Err(ApiError::Unauthorized) + Ok(Json(user)) + }, + _ => Err(ApiError::Unauthorized), + } } #[utoipa::path( @@ -177,9 +222,9 @@ async fn login( ) )] /// Destroys the session and logs the user out. -async fn logout(mut session: WritableSession) -> ApiResult<()> { - session.destroy(); - if !session.is_destroyed() { +async fn logout(session: Session) -> ApiResult<()> { + session.delete(); + if !matches!(session.deleted(), Some(SessionDeletion::Deleted)) { return Err(ApiError::InternalServerError( "Failed to destroy session".to_string(), )); @@ -201,7 +246,7 @@ async fn logout(mut session: WritableSession) -> ApiResult<()> { /// Attempts to register a new user. If no users exist in the database, the user is registered as a server owner. /// Otherwise, the registration is rejected by all users except the server owner. pub async fn register( - session: ReadableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { @@ -211,11 +256,11 @@ pub async fn register( let mut user_role = UserRole::default(); - let session_user = session.get::("user"); + let session_user = session.get::(SESSION_USER_KEY)?; // TODO: move nested if to if let once stable if let Some(user) = session_user { - if !user.is_admin() { + if !user.is_server_owner() { return Err(ApiError::Forbidden(String::from( "You do not have permission to access this resource.", ))); diff --git a/apps/server/src/routers/api/v1/epub.rs b/apps/server/src/routers/api/v1/epub.rs index 4ecc32a66..1324bf052 100644 --- a/apps/server/src/routers/api/v1/epub.rs +++ b/apps/server/src/routers/api/v1/epub.rs @@ -6,13 +6,13 @@ use axum::{ routing::{get, put}, Json, Router, }; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::chrono::Utc; use stump_core::{ db::entity::{Epub, ReadProgress, UpdateEpubProgress}, filesystem::media::EpubProcessor, prisma::{media, media_annotation, read_progress, user}, }; +use tower_sessions::Session; use crate::{ config::state::AppState, @@ -38,7 +38,7 @@ pub(crate) fn mount(app_state: AppState) -> Router { async fn get_epub_by_id( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let user_id = get_session_user(&session)?.id; @@ -71,7 +71,7 @@ async fn get_epub_by_id( async fn update_epub_progress( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); diff --git a/apps/server/src/routers/api/v1/filesystem.rs b/apps/server/src/routers/api/v1/filesystem.rs index de1ff56b6..9e9dff226 100644 --- a/apps/server/src/routers/api/v1/filesystem.rs +++ b/apps/server/src/routers/api/v1/filesystem.rs @@ -4,7 +4,6 @@ use axum::{ routing::post, Json, Router, }; -use axum_sessions::extractors::ReadableSession; use std::path::Path; use stump_core::{ db::query::pagination::{PageQuery, Pageable}, @@ -13,6 +12,7 @@ use stump_core::{ PathUtils, }, }; +use tower_sessions::Session; use tracing::trace; use crate::{ @@ -48,7 +48,7 @@ pub(crate) fn mount(app_state: AppState) -> Router { /// List the contents of a directory on the file system at a given (optional) path. If no path /// is provided, the file system root directory contents is returned. pub async fn list_directory( - session: ReadableSession, + session: Session, pagination: Query, input: Json>, ) -> ApiResult>> { diff --git a/apps/server/src/routers/api/v1/library.rs b/apps/server/src/routers/api/v1/library.rs index 6534587bd..54e706710 100644 --- a/apps/server/src/routers/api/v1/library.rs +++ b/apps/server/src/routers/api/v1/library.rs @@ -5,11 +5,11 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::{raw, Direction}; use serde::Deserialize; use serde_qs::axum::QsQuery; use std::{path, str::FromStr}; +use tower_sessions::Session; use tracing::{debug, error, trace}; use utoipa::ToSchema; @@ -478,7 +478,7 @@ pub(crate) fn get_library_thumbnail( async fn get_library_thumbnail_handler( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); @@ -557,7 +557,7 @@ pub struct PatchLibraryThumbnail { async fn patch_library_thumbnail( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, Json(body): Json, ) -> ApiResult { get_session_admin_user(&session)?; @@ -754,7 +754,7 @@ async fn scan_library( Path(id): Path, State(ctx): State, query: Query, - session: ReadableSession, + session: Session, ) -> Result<(), ApiError> { let db = ctx.get_db(); let _user = get_session_admin_user(&session)?; @@ -793,7 +793,7 @@ async fn scan_library( )] /// Create a new library. Will queue a ScannerJob to scan the library, and return the library async fn create_library( - session: ReadableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { @@ -917,7 +917,7 @@ async fn create_library( )] /// Update a library by id, if the current user is a SERVER_OWNER. async fn update_library( - session: ReadableSession, + session: Session, State(ctx): State, Path(id): Path, Json(input): Json, @@ -1030,7 +1030,7 @@ async fn update_library( )] /// Delete a library by id async fn delete_library( - session: ReadableSession, + session: Session, Path(id): Path, State(ctx): State, ) -> ApiResult> { diff --git a/apps/server/src/routers/api/v1/log.rs b/apps/server/src/routers/api/v1/log.rs index 0b3b6cb2a..1bb79a42c 100644 --- a/apps/server/src/routers/api/v1/log.rs +++ b/apps/server/src/routers/api/v1/log.rs @@ -4,7 +4,6 @@ use axum::{ routing::get, Json, Router, }; -use axum_sessions::extractors::ReadableSession; use futures_util::Stream; use notify::{EventKind, RecursiveMode, Watcher}; use prisma_client_rust::chrono::{DateTime, Utc}; @@ -15,6 +14,7 @@ use std::{ }; use stump_core::{config::logging::get_log_file, db::entity::LogMetadata}; use tokio::sync::broadcast; +use tower_sessions::Session; use crate::{ config::state::AppState, @@ -131,7 +131,7 @@ async fn tail_log_file() -> Sse>> { )] /// Get information about the Stump log file, located at STUMP_CONFIG_DIR/Stump.log, or /// ~/.stump/Stump.log by default. Information such as the file size, last modified date, etc. -async fn get_logfile_info(session: ReadableSession) -> ApiResult> { +async fn get_logfile_info(session: Session) -> ApiResult> { get_session_admin_user(&session)?; let log_file_path = get_log_file(); @@ -164,7 +164,7 @@ async fn get_logfile_info(session: ReadableSession) -> ApiResult ApiResult<()> { +async fn clear_logs(session: Session) -> ApiResult<()> { get_session_admin_user(&session)?; let log_file_path = get_log_file(); diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index ee4025d5a..d6efa8d5c 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -5,7 +5,6 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::{and, operator::or, or, Direction}; use serde::Deserialize; use serde_qs::axum::QsQuery; @@ -28,6 +27,7 @@ use stump_core::{ media_metadata, read_progress, series, series_metadata, tag, user, PrismaClient, }, }; +use tower_sessions::Session; use utoipa::ToSchema; use crate::{ @@ -268,7 +268,7 @@ async fn get_media( filter_query: QsQuery>, pagination_query: Query, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult>>> { let FilterableQuery { filters, ordering } = filter_query.0.get(); let pagination = pagination_query.0.get(); @@ -364,7 +364,7 @@ async fn get_media( async fn get_duplicate_media( pagination: Query, State(ctx): State, - _session: ReadableSession, + _session: Session, ) -> ApiResult>>> { let media_dao = MediaDAO::new(ctx.db.clone()); @@ -399,7 +399,7 @@ async fn get_duplicate_media( /// total number of pages available (i.e not completed). async fn get_in_progress_media( State(ctx): State, - session: ReadableSession, + session: Session, pagination_query: Query, ) -> ApiResult>>> { let user = get_session_user(&session)?; @@ -491,7 +491,7 @@ async fn get_in_progress_media( async fn get_recently_added_media( filter_query: QsQuery>, pagination_query: Query, - session: ReadableSession, + session: Session, State(ctx): State, ) -> ApiResult>>> { let FilterableQuery { filters, .. } = filter_query.0.get(); @@ -578,7 +578,7 @@ async fn get_media_by_id( Path(id): Path, params: Query, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let db = ctx.get_db(); let user = get_session_user(&session)?; @@ -632,7 +632,7 @@ async fn get_media_by_id( async fn get_media_file( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); @@ -677,7 +677,7 @@ async fn get_media_file( async fn convert_media( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> Result<(), ApiError> { let db = ctx.get_db(); @@ -727,7 +727,7 @@ async fn convert_media( async fn get_media_page( Path((id, page)): Path<(String, i32)>, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); @@ -764,7 +764,7 @@ async fn get_media_page( pub(crate) async fn get_media_thumbnail_by_id( id: String, db: &PrismaClient, - session: &ReadableSession, + session: &Session, ) -> ApiResult<(ContentType, Vec)> { let user = get_session_user(session)?; let age_restrictions = user @@ -862,7 +862,7 @@ pub(crate) fn get_media_thumbnail( async fn get_media_thumbnail_handler( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { tracing::trace!(?id, "get_media_thumbnail"); let db = ctx.get_db(); @@ -896,7 +896,7 @@ pub struct PatchMediaThumbnail { async fn patch_media_thumbnail( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, Json(body): Json, ) -> ApiResult { get_session_admin_user(&session)?; @@ -976,7 +976,7 @@ async fn patch_media_thumbnail( async fn update_media_progress( Path((id, page)): Path<(String, i32)>, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let db = ctx.get_db(); let user_id = get_session_user(&session)?.id; diff --git a/apps/server/src/routers/api/v1/reading_list.rs b/apps/server/src/routers/api/v1/reading_list.rs index 55172cfa0..f048342e4 100644 --- a/apps/server/src/routers/api/v1/reading_list.rs +++ b/apps/server/src/routers/api/v1/reading_list.rs @@ -9,7 +9,6 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::{and, or}; use stump_core::{ db::{ @@ -18,6 +17,7 @@ use stump_core::{ }, prisma::{reading_list, reading_list_rbac, user}, }; +use tower_sessions::Session; use tracing::trace; pub(crate) fn mount() -> Router { @@ -119,7 +119,7 @@ pub(crate) fn apply_pagination<'a>( async fn get_reading_list( State(ctx): State, pagination_query: Query, - session: ReadableSession, + session: Session, ) -> ApiResult>>> { let user = get_session_user(&session)?; let user_id = user.id.clone(); @@ -185,7 +185,7 @@ async fn get_reading_list( ) )] async fn create_reading_list( - session: ReadableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { @@ -252,7 +252,7 @@ async fn create_reading_list( async fn get_reading_list_by_id( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let user_id = get_session_user(&session)?.id; let db = ctx.get_db(); @@ -296,7 +296,7 @@ async fn get_reading_list_by_id( ) )] async fn update_reading_list( - session: ReadableSession, + session: Session, Path(id): Path, State(ctx): State, Json(input): Json, @@ -363,7 +363,7 @@ async fn update_reading_list( ) )] async fn delete_reading_list_by_id( - session: ReadableSession, + session: Session, Path(id): Path, State(ctx): State, ) -> ApiResult> { diff --git a/apps/server/src/routers/api/v1/series.rs b/apps/server/src/routers/api/v1/series.rs index 59b1010e5..e3a49881a 100644 --- a/apps/server/src/routers/api/v1/series.rs +++ b/apps/server/src/routers/api/v1/series.rs @@ -5,7 +5,6 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::{or, Direction}; use serde::Deserialize; use serde_qs::axum::QsQuery; @@ -32,6 +31,7 @@ use stump_core::{ series_metadata, }, }; +use tower_sessions::Session; use tracing::{error, trace}; use utoipa::ToSchema; @@ -182,7 +182,7 @@ async fn get_series( pagination_query: Query, relation_query: Query, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult>>> { let FilterableQuery { ordering, filters } = filter_query.0.get(); let pagination = pagination_query.0.get(); @@ -303,7 +303,7 @@ async fn get_series_by_id( query: Query, Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let db = ctx.get_db(); @@ -373,7 +373,7 @@ async fn get_series_by_id( async fn get_recently_added_series_handler( State(ctx): State, pagination: Query, - session: ReadableSession, + session: Session, ) -> ApiResult>>> { if pagination.page.is_none() { return Err(ApiError::BadRequest( @@ -440,7 +440,7 @@ pub(crate) fn get_series_thumbnail( async fn get_series_thumbnail_handler( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); @@ -522,7 +522,7 @@ pub struct PatchSeriesThumbnail { async fn patch_series_thumbnail( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, Json(body): Json, ) -> ApiResult { get_session_admin_user(&session)?; @@ -607,7 +607,7 @@ async fn patch_series_thumbnail( async fn get_series_media( pagination_query: Query, ordering: Query, - session: ReadableSession, + session: Session, Path(id): Path, State(ctx): State, ) -> ApiResult>>> { @@ -726,7 +726,7 @@ async fn get_series_media( async fn get_next_in_series( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult>> { let db = ctx.get_db(); let user_id = get_session_user(&session)?.id; diff --git a/apps/server/src/routers/api/v1/user.rs b/apps/server/src/routers/api/v1/user.rs index ffa85b97f..fe2aba7f5 100644 --- a/apps/server/src/routers/api/v1/user.rs +++ b/apps/server/src/routers/api/v1/user.rs @@ -5,7 +5,6 @@ use axum::{ Json, Router, }; use axum_extra::extract::Query; -use axum_sessions::extractors::{ReadableSession, WritableSession}; use prisma_client_rust::{chrono::Utc, Direction}; use stump_core::{ db::{ @@ -17,16 +16,14 @@ use stump_core::{ }, prisma::{user, user_login_activity, user_preferences, PrismaClient}, }; +use tower_sessions::Session; use tracing::{debug, trace}; use crate::{ - config::state::AppState, + config::{session::SESSION_USER_KEY, state::AppState}, errors::{ApiError, ApiResult}, middleware::auth::Auth, - utils::{ - get_hash_cost, get_session_admin_user, get_session_user, - get_writable_session_user, UserQueryRelation, - }, + utils::{get_hash_cost, get_session_admin_user, get_session_user, UserQueryRelation}, }; use super::auth::LoginOrRegisterArgs; @@ -106,7 +103,7 @@ async fn get_users( State(ctx): State, relation_query: Query, pagination_query: Query, - session: ReadableSession, + session: Session, ) -> ApiResult>>> { get_session_admin_user(&session)?; @@ -169,7 +166,7 @@ async fn get_users( )] async fn get_user_login_activity( State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult>> { get_session_admin_user(&session)?; @@ -201,7 +198,7 @@ async fn get_user_login_activity( )] async fn delete_user_login_activity( State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { get_session_admin_user(&session)?; @@ -283,7 +280,7 @@ async fn update_preferences( )] /// Creates a new user. async fn create_user( - session: ReadableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { @@ -336,18 +333,18 @@ async fn create_user( )] /// Updates the session user async fn update_current_user( - mut writable_session: WritableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); - let user = get_writable_session_user(&writable_session)?; + let user = get_session_user(&session)?; let updated_user = update_user(db, user.id, input).await?; debug!(?updated_user, "Updated user"); - writable_session - .insert("user", updated_user.clone()) + session + .insert(SESSION_USER_KEY, updated_user.clone()) .map_err(|e| { ApiError::InternalServerError(format!("Failed to update session: {}", e)) })?; @@ -369,13 +366,13 @@ async fn update_current_user( )] /// Updates a user's preferences. async fn update_current_user_preferences( - mut writable_session: WritableSession, + session: Session, State(ctx): State, Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); - let user = get_writable_session_user(&writable_session)?; + let user = get_session_user(&session)?; let user_preferences = user.user_preferences.clone().unwrap_or_default(); trace!(user_id = ?user.id, ?user_preferences, updates = ?input, "Updating viewer's preferences"); @@ -383,7 +380,7 @@ async fn update_current_user_preferences( let updated_preferences = update_preferences(db, user_preferences.id, input).await?; debug!(?updated_preferences, "Updated user preferences"); - writable_session + session .insert( "user", User { @@ -417,7 +414,7 @@ async fn update_current_user_preferences( async fn delete_user_by_id( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); @@ -467,7 +464,7 @@ async fn delete_user_by_id( async fn get_user_by_id( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { get_session_admin_user(&session)?; let db = ctx.get_db(); @@ -504,7 +501,7 @@ async fn get_user_by_id( async fn get_user_login_activity_by_id( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult>> { let user = get_session_user(&session)?; @@ -545,13 +542,13 @@ async fn get_user_login_activity_by_id( )] /// Updates a user by ID. async fn update_user_handler( - mut writable_session: WritableSession, + session: Session, State(ctx): State, Path(id): Path, Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); - let user = get_writable_session_user(&writable_session)?; + let user = get_session_user(&session)?; // TODO: determine what a server owner can update. if user.id != id { @@ -561,8 +558,8 @@ async fn update_user_handler( let updated_user = update_user(db, id, input).await?; debug!(?updated_user, "Updated user"); - writable_session - .insert("user", updated_user.clone()) + session + .insert(SESSION_USER_KEY, updated_user.clone()) .map_err(|e| { ApiError::InternalServerError(format!("Failed to update session: {}", e)) })?; @@ -589,7 +586,7 @@ async fn update_user_handler( async fn get_user_preferences( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult> { let db = ctx.get_db(); let user = get_session_user(&session)?; @@ -633,7 +630,7 @@ async fn get_user_preferences( )] /// Updates a user's preferences. async fn update_user_preferences( - mut writable_session: WritableSession, + session: Session, State(ctx): State, Path(id): Path, Json(input): Json, @@ -641,7 +638,7 @@ async fn update_user_preferences( trace!(?id, ?input, "Updating user preferences"); let db = ctx.get_db(); - let user = get_writable_session_user(&writable_session)?; + let user = get_session_user(&session)?; let user_preferences = user.user_preferences.clone().unwrap_or_default(); if user_preferences.id != input.id { @@ -651,9 +648,9 @@ async fn update_user_preferences( let updated_preferences = update_preferences(db, user_preferences.id, input).await?; debug!(?updated_preferences, "Updated user preferences"); - writable_session + session .insert( - "user", + SESSION_USER_KEY, User { user_preferences: Some(updated_preferences.clone()), ..user diff --git a/apps/server/src/routers/opds.rs b/apps/server/src/routers/opds.rs index 64891c445..4bcf55627 100644 --- a/apps/server/src/routers/opds.rs +++ b/apps/server/src/routers/opds.rs @@ -4,7 +4,6 @@ use axum::{ routing::get, Router, }; -use axum_sessions::extractors::ReadableSession; use prisma_client_rust::{chrono, Direction}; use stump_core::{ db::{query::pagination::PageQuery, PrismaCountTrait}, @@ -20,6 +19,7 @@ use stump_core::{ }, prisma::{library, media, read_progress, series, user}, }; +use tower_sessions::Session; use tracing::{debug, trace, warn}; use crate::{ @@ -185,10 +185,7 @@ async fn catalog() -> ApiResult { Ok(Xml(feed.build()?)) } -async fn keep_reading( - State(ctx): State, - session: ReadableSession, -) -> ApiResult { +async fn keep_reading(State(ctx): State, session: Session) -> ApiResult { let db = ctx.get_db(); let user_id = get_session_user(&session)?.id; @@ -507,7 +504,7 @@ fn handle_opds_image_response( async fn get_book_thumbnail( Path(id): Path, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); let user = get_session_user(&session)?; @@ -535,7 +532,7 @@ async fn get_book_page( Path((id, page)): Path<(String, i32)>, State(ctx): State, pagination: Query, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); @@ -600,7 +597,7 @@ async fn get_book_page( async fn download_book( Path((id, filename)): Path<(String, String)>, State(ctx): State, - session: ReadableSession, + session: Session, ) -> ApiResult { let db = ctx.get_db(); let user = get_session_user(&session)?; diff --git a/apps/server/src/utils/auth.rs b/apps/server/src/utils/auth.rs index 7a0f97645..824bc709e 100644 --- a/apps/server/src/utils/auth.rs +++ b/apps/server/src/utils/auth.rs @@ -1,7 +1,10 @@ -use axum_sessions::extractors::{ReadableSession, WritableSession}; use stump_core::db::entity::User; +use tower_sessions::Session; -use crate::errors::{ApiError, ApiResult, AuthError}; +use crate::{ + config::session::SESSION_USER_KEY, + errors::{ApiError, ApiResult, AuthError}, +}; #[derive(Debug)] pub struct DecodedCredentials { @@ -35,35 +38,15 @@ pub fn decode_base64_credentials( Ok(DecodedCredentials { username, password }) } -pub fn get_session_user(session: &ReadableSession) -> ApiResult { - if let Some(user) = session.get::("user") { +pub fn get_session_user(session: &Session) -> ApiResult { + if let Some(user) = session.get::(SESSION_USER_KEY)? { Ok(user) } else { Err(ApiError::Unauthorized) } } -pub fn get_writable_session_user(session: &WritableSession) -> ApiResult { - if let Some(user) = session.get::("user") { - Ok(user) - } else { - Err(ApiError::Unauthorized) - } -} - -// pub fn get_writable_session_admin_user(session: &WritableSession) -> ApiResult { -// let user = get_writable_session_user(session)?; - -// if user.is_admin() { -// Ok(user) -// } else { -// Err(ApiError::Forbidden( -// "You do not have permission to access this resource.".to_string(), -// )) -// } -// } - -pub fn get_session_admin_user(session: &ReadableSession) -> ApiResult { +pub fn get_session_admin_user(session: &Session) -> ApiResult { let user = get_session_user(session)?; if user.is_admin() { diff --git a/core/prisma/migrations/20231002200152_account_locking/migration.sql b/core/prisma/migrations/20231002200152_account_locking/migration.sql new file mode 100644 index 000000000..b76d838ea --- /dev/null +++ b/core/prisma/migrations/20231002200152_account_locking/migration.sql @@ -0,0 +1,22 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "hashed_password" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'MEMBER', + "avatar_url" TEXT, + "last_login" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" DATETIME, + "is_locked" BOOLEAN NOT NULL DEFAULT false, + "user_preferences_id" TEXT, + CONSTRAINT "users_user_preferences_id_fkey" FOREIGN KEY ("user_preferences_id") REFERENCES "user_preferences" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_users" ("avatar_url", "created_at", "deleted_at", "hashed_password", "id", "last_login", "role", "user_preferences_id", "username") SELECT "avatar_url", "created_at", "deleted_at", "hashed_password", "id", "last_login", "role", "user_preferences_id", "username" FROM "users"; +DROP TABLE "users"; +ALTER TABLE "new_users" RENAME TO "users"; +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); +CREATE UNIQUE INDEX "users_user_preferences_id_key" ON "users"("user_preferences_id"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index fad8609ce..3e48a6878 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -24,6 +24,8 @@ model User { created_at DateTime @default(now()) // The timestamp of when the user was *soft* deleted. deleted_at DateTime? + // A flag indicating whether or not the user is locked. A locked user will not be able to login. + is_locked Boolean @default(false) ///////////////////////////////////////////// ///////////////// RELATIONS ///////////////// @@ -41,6 +43,7 @@ model User { login_activity UserLoginActivity[] media_annotations MediaAnnotation[] + sessions Session[] @@map("users") } @@ -67,6 +70,26 @@ model UserLoginActivity { @@map("user_login_activity") } +model Session { + id String @id @default(uuid()) + // The timestamp of when the session was created. + created_at DateTime @default(now()) + // The timestamp of when the session expires. + expires_at DateTime + // The session data. This is a JSON blob. + data Bytes + + ///////////////////////////////////////////// + ///////////////// RELATIONS ///////////////// + ///////////////////////////////////////////// + + // The user who owns the session. + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + + @@map("sessions") +} + model AgeRestriction { // The minimum age for the user. Ex: if age is 10, then age Int diff --git a/core/src/db/entity/user.rs b/core/src/db/entity/user.rs index d3d4f6e5a..46a44f343 100644 --- a/core/src/db/entity/user.rs +++ b/core/src/db/entity/user.rs @@ -22,17 +22,19 @@ pub struct User { pub id: String, pub username: String, pub role: String, + pub avatar_url: Option, + pub created_at: String, + pub last_login: Option, + pub is_locked: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub user_preferences: Option, - pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub login_activity: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub age_restriction: Option, #[serde(skip_serializing_if = "Option::is_none")] pub read_progresses: Option>, - pub created_at: String, - pub last_login: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub login_activity: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)] @@ -110,6 +112,7 @@ impl From for User { created_at: data.created_at.to_rfc3339(), last_login: data.last_login.map(|dt| dt.to_rfc3339()), login_activity, + is_locked: data.is_locked, } } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 000000000..7b1a7b735 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cli-bin" +path = "bin/main.rs" + +[dependencies] +clap = { version = "4.4.6", features = ["derive", "env"] } +stump_core = { path = "../../core" } + +tokio = { workspace = true } +thiserror = { workspace = true } +prisma-client-rust = { workspace = true } +dialoguer = "0.11.0" +indicatif = "0.17.7" + +bcrypt = { workspace = true } diff --git a/crates/cli/bin/main.rs b/crates/cli/bin/main.rs new file mode 100644 index 000000000..ec03ff905 --- /dev/null +++ b/crates/cli/bin/main.rs @@ -0,0 +1,22 @@ +use cli::{handle_command, Cli, Parser}; +use stump_core::StumpCore; + +/// This is just an example of how to use this crate. It is going to be used in the +/// server app. This is not meant to be a real CLI binary. +#[tokio::main] +async fn main() { + let app = Cli::parse(); + + let environment_load_result = StumpCore::init_environment(); + if let Err(err) = environment_load_result { + println!("Failed to load environment variables: {:?}", err); + } + + if let Some(command) = app.command { + handle_command(command, app.config) + .await + .expect("Failed to handle command"); + } else { + println!("No command provided! This would start the server IRL"); + } +} diff --git a/crates/cli/src/commands/account.rs b/crates/cli/src/commands/account.rs new file mode 100644 index 000000000..c35ca7a55 --- /dev/null +++ b/crates/cli/src/commands/account.rs @@ -0,0 +1,156 @@ +use std::{thread, time::Duration}; + +use clap::Subcommand; +use dialoguer::{theme::ColorfulTheme, Password}; +use stump_core::{db::create_client, prisma::user}; + +use crate::{commands::chain_optional_iter, error::CliResult, CliConfig, CliError}; + +use super::default_progress_spinner; + +/// Subcommands for interacting with Stump accounts +#[derive(Subcommand, Debug)] +pub enum Account { + /// Lock an account, preventing any further logins until unlocked + Lock { + /// The username of the account to lock + #[clap(long)] + username: String, + }, + /// Unlock an account, allowing logins again + Unlock { + /// The username of the account to unlock + #[clap(long)] + username: String, + }, + /// List all accounts, optionally filtering by locked status + List { + /// Only list locked accounts + #[clap(long)] + locked: Option, + }, + /// Reset the password for an account + ResetPassword { + /// The username of the account to reset the password for + #[clap(long)] + username: String, + }, +} + +pub async fn handle_account_command( + command: Account, + config: CliConfig, +) -> CliResult<()> { + match command { + Account::Lock { username } => set_account_lock_status(username, true).await, + Account::Unlock { username } => set_account_lock_status(username, false).await, + Account::List { locked } => print_accounts(locked).await, + Account::ResetPassword { username } => { + reset_account_password(username, config.password_hash_cost).await + }, + } +} + +async fn set_account_lock_status(username: String, lock: bool) -> CliResult<()> { + let progress = default_progress_spinner(); + progress.set_message(if lock { + "Locking account..." + } else { + "Unlocking account..." + }); + + let client = create_client().await; + + let affected_rows = client + .user() + .update_many( + vec![user::username::equals(username)], + vec![user::is_locked::set(lock)], + ) + .exec() + .await?; + + thread::sleep(Duration::from_millis(500)); + + if affected_rows == 0 { + progress.abandon_with_message("No account with that username was found"); + Err(CliError::OperationFailed(String::from( + "No account with that username was found", + ))) + } else { + progress.finish_with_message(if lock { + "Account locked successfully!" + } else { + "Account unlocked successfully!" + }); + Ok(()) + } +} + +async fn reset_account_password(username: String, hash_cost: u32) -> CliResult<()> { + let client = create_client().await; + + let theme = &ColorfulTheme::default(); + let builder = Password::with_theme(theme) + .with_prompt("Enter a new password") + .with_confirmation("Confirm password", "Passwords don't match!"); + let password = builder.interact()?; + + let progress = default_progress_spinner(); + progress.set_message("Hashing and salting password..."); + let hashed_password = + bcrypt::hash(password, hash_cost).expect("Failed to hash password"); + + progress.set_message("Updating account..."); + + let affected_rows = client + .user() + .update_many( + vec![user::username::equals(username)], + vec![user::hashed_password::set(hashed_password)], + ) + .exec() + .await?; + + thread::sleep(Duration::from_millis(500)); + + if affected_rows == 0 { + progress.abandon_with_message("No account with that username was found"); + Err(CliError::OperationFailed(String::from( + "No account with that username was found", + ))) + } else { + progress.finish_with_message("Account password updated successfully!"); + Ok(()) + } +} + +// TODO: print pretty table +// TODO: handle empty state +async fn print_accounts(locked: Option) -> CliResult<()> { + let progress = default_progress_spinner(); + progress.set_message("Fetching accounts..."); + + let client = create_client().await; + + let users = client + .user() + .find_many(chain_optional_iter( + [], + [locked.map(user::is_locked::equals)], + )) + .exec() + .await?; + + progress.finish_with_message("Accounts fetched successfully!"); + + for user in users { + println!( + "{}: {}", + user.username, + if user.is_locked { "locked" } else { "unlocked" } + ); + } + + Ok(()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 000000000..827910c4d --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,47 @@ +mod account; + +use std::time::Duration; + +use clap::Subcommand; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::{error::CliResult, CliConfig}; + +use self::account::Account; + +#[derive(Subcommand, Debug)] +pub enum Commands { + #[command(subcommand)] + Account(Account), +} + +pub async fn handle_command(command: Commands, config: CliConfig) -> CliResult<()> { + match command { + Commands::Account(account) => { + account::handle_account_command(account, config).await + }, + } +} + +pub(crate) fn default_progress_spinner() -> ProgressBar { + let progress = ProgressBar::new_spinner(); + progress.enable_steady_tick(Duration::from_millis(120)); + progress.set_style( + ProgressStyle::with_template("{spinner} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), + ); + progress +} + +pub(crate) fn chain_optional_iter( + required: impl IntoIterator, + optional: impl IntoIterator>, +) -> Vec { + required + .into_iter() + .map(Some) + .chain(optional) + .flatten() + .collect() +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs new file mode 100644 index 000000000..89288dcc9 --- /dev/null +++ b/crates/cli/src/config.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Default, Parser)] +pub struct CliConfig { + /// The path to the configuration directory + #[clap(long, env = "STUMP_CONFIG_DIR")] + pub config_dir: Option, + /// The desired cost for password hashing. Defaults to 12. + #[clap(long, env = "HASH_COST", default_value = "12")] + pub password_hash_cost: u32, +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs new file mode 100644 index 000000000..d4bb551b3 --- /dev/null +++ b/crates/cli/src/error.rs @@ -0,0 +1,13 @@ +use prisma_client_rust::QueryError; + +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error("{0}")] + DialogError(#[from] dialoguer::Error), + #[error("{0}")] + OperationFailed(String), + #[error("{0}")] + QueryError(#[from] QueryError), +} + +pub type CliResult = Result; diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 000000000..22c9aff9d --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,37 @@ +mod commands; +mod config; +mod error; + +pub use commands::{handle_command, Commands}; +pub use config::CliConfig; +pub use error::CliError; + +pub use clap::Parser; + +/// A CLI for Stump. If no subcommand is provided, the server will be started. +#[derive(Parser)] +#[command(name = "stump")] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[clap(flatten)] + pub config: CliConfig, + + /// The available subcommands. If no subcommand is provided, the server will be started + #[command(subcommand)] + pub command: Option, +} + +// # start the server +// $ ./server + +// # unlock an account +// $ ./server account lock --username + +// # freeze an account +// $ ./server account unlock --username + +// # list all frozen accounts +// $ ./server account list --locked + +// # reset password for a user +// $ ./server account reset-password --username diff --git a/packages/client/src/queries/auth.ts b/packages/client/src/queries/auth.ts index 1fc3286ed..284916919 100644 --- a/packages/client/src/queries/auth.ts +++ b/packages/client/src/queries/auth.ts @@ -43,23 +43,23 @@ export function useLoginOrRegister({ onSuccess, onError }: UseLoginOrRegisterOpt } }, [claimCheck]) - const { isLoading: isLoggingIn, mutateAsync: loginUser } = useMutation( - ['loginUser'], - authApi.login, - { - onError: (err) => { - onError?.(err) - }, - onSuccess: (res) => { - if (!res.data) { - onError?.(res) - } else { - queryClient.invalidateQueries(['getLibraries']) - onSuccess?.(res.data) - } - }, + const { + isLoading: isLoggingIn, + mutateAsync: loginUser, + error: loginError, + } = useMutation(['loginUser'], authApi.login, { + onError: (err) => { + onError?.(err) }, - ) + onSuccess: (res) => { + if (!res.data) { + onError?.(res) + } else { + queryClient.invalidateQueries(['getLibraries']) + onSuccess?.(res.data) + } + }, + }) const { isLoading: isRegistering, mutateAsync: registerUser } = useMutation( [authQueryKeys.register], @@ -71,6 +71,7 @@ export function useLoginOrRegister({ onSuccess, onError }: UseLoginOrRegisterOpt isClaimed, isLoggingIn, isRegistering, + loginError, loginUser, registerUser, } diff --git a/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx b/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx index c538d0bbb..52b2cecf1 100644 --- a/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx +++ b/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx @@ -1,6 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { isAxiosError } from '@stump/api' import { queryClient, useLoginOrRegister, useUserStore } from '@stump/client' -import { Button, cx, Form, Heading, Input, Text } from '@stump/components' +import { Alert, Button, cx, Form, Heading, Input } from '@stump/components' +import { ShieldAlert } from 'lucide-react' import { FieldValues, useForm } from 'react-hook-form' import { toast } from 'react-hot-toast' import { Navigate } from 'react-router' @@ -19,10 +21,17 @@ export default function LoginOrClaimScene() { })) const { t } = useLocaleContext() - const { isClaimed, isCheckingClaimed, loginUser, registerUser, isLoggingIn, isRegistering } = - useLoginOrRegister({ - onSuccess: setUser, - }) + const { + isClaimed, + isCheckingClaimed, + loginUser, + registerUser, + isLoggingIn, + isRegistering, + loginError, + } = useLoginOrRegister({ + onSuccess: setUser, + }) const schema = z.object({ password: z.string().min(1, { message: t('authScene.form.validation.missingPassword') }), @@ -101,9 +110,27 @@ export default function LoginOrClaimScene() { } } + const renderError = () => { + if (!loginError) return null + + // If the response is a 403, and we are NOT claiming, it is likely because + // the account login is disabled (i.e. the account is locked). + if (isAxiosError(loginError) && loginError.response?.status === 403) { + const message = loginError.response?.data as string + return ( + + {message || 'An unknown error occurred'} + + ) + } + + return null + } + return (
{renderHeader()} + {renderError()}
, onClick: () => setDeletingUser(user), }, + { + disabled: isSelf, + label: `${user.is_locked ? 'Unlock' : 'Lock'} account`, + leftIcon: user.is_locked ? ( + + ) : ( + + ), + onClick: noop, + }, ], [setDeletingUser, user, isSelf], ) - if (!isServerOwner) { + if (!isServerOwner || isSelf) { return null } diff --git a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx index aad56a55f..ecd8100ad 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx @@ -1,4 +1,4 @@ -import { Text } from '@stump/components' +import { Badge, Text } from '@stump/components' import { User } from '@stump/types' import { createColumnHelper, getCoreRowModel } from '@tanstack/react-table' import dayjs from 'dayjs' @@ -53,6 +53,15 @@ const baseColumns = [ ), header: 'Last login', }), + columnHelper.display({ + cell: ({ row: { original } }) => ( + + {original.is_locked ? 'Locked' : 'Active'} + + ), + header: 'Status', + id: 'is_locked', + }), columnHelper.display({ cell: ({ row: { original } }) => (
diff --git a/packages/types/core.ts b/packages/types/core.ts index fd317b5dc..e2fbe3e4c 100644 --- a/packages/types/core.ts +++ b/packages/types/core.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-types */ // DO NOT MODIFY THIS FILE, IT IS AUTOGENERATED -export type User = { id: string; username: string; role: string; user_preferences?: UserPreferences | null; avatar_url: string | null; age_restriction?: AgeRestriction | null; read_progresses?: ReadProgress[] | null; created_at: string; last_login: string | null; login_activity?: LoginActivity[] | null } +export type User = { id: string; username: string; role: string; avatar_url: string | null; created_at: string; last_login: string | null; is_locked: boolean; user_preferences?: UserPreferences | null; login_activity?: LoginActivity[] | null; age_restriction?: AgeRestriction | null; read_progresses?: ReadProgress[] | null } export type UserRole = "SERVER_OWNER" | "MEMBER" @@ -93,7 +93,7 @@ export type ImageFormat = "Webp" | "Jpeg" | "JpegXl" | "Png" /** * Options for processing images throughout Stump. */ -export type ImageProcessorOptions = { resize_options: ImageResizeOptions | null; format: ImageFormat; quality: number | null } +export type ImageProcessorOptions = { resize_options: ImageResizeOptions | null; format: ImageFormat; quality: number | null; page: number | null } export type DirectoryListing = { parent: string | null; files: DirectoryListingFile[] } diff --git a/scripts/release/Dockerfile.debian b/scripts/release/Dockerfile.debian index a6c0ae3aa..18ba237e4 100644 --- a/scripts/release/Dockerfile.debian +++ b/scripts/release/Dockerfile.debian @@ -23,7 +23,7 @@ RUN mv ./apps/web/dist build # Cargo Build Stage # ------------------------------------------------------------------------------ -FROM rust:1.68.0-slim-buster AS builder +FROM rust:1.72.1-slim-buster AS builder ARG GIT_REV ENV GIT_REV=${GIT_REV} From a38326c9002ad639d7899043d0f62ca4a8dfb1b8 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 3 Oct 2023 17:08:58 -0700 Subject: [PATCH 2/4] small fixes and updates --- apps/docs/pages/contributing.mdx | 19 +++- apps/docs/pages/guides/_meta.json | 1 + apps/docs/pages/guides/access-control.mdx | 5 + apps/docs/pages/guides/cli.md | 48 ++++++++ apps/docs/pages/guides/configuration.md | 10 +- apps/docs/pages/guides/readers.mdx | 2 +- apps/docs/pages/guides/rest-api.md | 18 +-- apps/docs/pages/guides/user-management.md | 33 ------ apps/docs/pages/guides/user-management.mdx | 82 ++++++++++++++ apps/server/src/routers/api/v1/auth.rs | 6 +- core/src/filesystem/image/process.rs | 1 + .../interface/src/components/table/Table.tsx | 106 ++++++++++-------- .../src/scenes/auth/LoginOrClaimScene.tsx | 4 +- .../scenes/settings/job/DeleteAllSection.tsx | 2 +- .../src/scenes/settings/job/JobScheduler.tsx | 4 +- .../login-activity/ClearActivitySection.tsx | 2 +- .../login-activity/LoginActivityTable.tsx | 26 +++-- .../user/user-table/CreateUserModal.tsx | 2 +- .../settings/user/user-table/UserTable.tsx | 2 +- 19 files changed, 260 insertions(+), 113 deletions(-) create mode 100644 apps/docs/pages/guides/cli.md delete mode 100644 apps/docs/pages/guides/user-management.md create mode 100644 apps/docs/pages/guides/user-management.mdx diff --git a/apps/docs/pages/contributing.mdx b/apps/docs/pages/contributing.mdx index 53cf68397..36626567a 100644 --- a/apps/docs/pages/contributing.mdx +++ b/apps/docs/pages/contributing.mdx @@ -6,7 +6,7 @@ If you're interested in supporting Stump's development, and you know how to code ## Developer Guide -Contributions are very **encouraged** and **welcome**! Be sure to review the [CONTRIBUTING.md](https://github.com/stumpapp/stump/blob/main/CONTRIBUTING.md) before getting started with your contribution. +Contributions are very **encouraged** and **welcome**! Be sure to review the [CONTRIBUTING.md](https://github.com/stumpapp/stump/blob/main/.github/CONTRIBUTING.md) before getting started with your contribution. If you're completely new to rust and/or web development, I put together a small set of [resources](#developer-resources) to get you started with Stump. @@ -44,6 +44,23 @@ moon run server:start desktop:desktop-dev Notice the general format is `moon run :`. You can supply multiple `:` pairs to run multiple tasks concurrently. +This isn't a requirement though! You can also run the apps directly with `pnpm`: + +```bash +# run the webapp + server +pnpm dev:web +# run the desktop app + server +pnpm dev:desktop +# run the docs website +pnpm docs dev +``` + +Or just `cargo` for the server: + +```bash +cargo run --package stump_server --bin stump_server +``` + ## Notes and Tips Stump has gotten quite large and complex, so I've put together a few notes and tips to help you get started. diff --git a/apps/docs/pages/guides/_meta.json b/apps/docs/pages/guides/_meta.json index ca29977d5..e15e94255 100644 --- a/apps/docs/pages/guides/_meta.json +++ b/apps/docs/pages/guides/_meta.json @@ -16,6 +16,7 @@ "readers": "Stump Readers", "opds": "OPDS", "rest-api": "REST API", + "cli": "CLI", "desktop": "Desktop Guides", "mobile": "Mobile Guides", "setup": "Setup Examples" diff --git a/apps/docs/pages/guides/access-control.mdx b/apps/docs/pages/guides/access-control.mdx index d6923f367..e526628d0 100644 --- a/apps/docs/pages/guides/access-control.mdx +++ b/apps/docs/pages/guides/access-control.mdx @@ -9,9 +9,14 @@ import { Callout } from 'nextra-theme-docs' Stump has a built-in access control systems that allows you to manage who has access to what. This is done through a combination of methods, including: +- RBAC (Role-based access control) - Age restrictions - Tag-based restrictions +## RBAC + +> This is not available yet + ## Age restrictions Age restrictions are set on a per-user basis, and are used to determine whether or not a user can access a book. For more information on user management, see the [User Management](/guides/user-management) page. diff --git a/apps/docs/pages/guides/cli.md b/apps/docs/pages/guides/cli.md new file mode 100644 index 000000000..9639e7ddb --- /dev/null +++ b/apps/docs/pages/guides/cli.md @@ -0,0 +1,48 @@ +# Command-line interface + +The Stump server ships with a built-in CLI tool that can be used for various tasks. At the time of writing, you can use it to: + +- Lock or unlock a user account (i.e. prevent or allow the user to log in) +- Reset a user's password + +## Usage + +The CLI tool is exposed via a few subcommands using the same executable as the server, itself. To see the available subcommands, run: + +```bash +./stump --help +``` + +This will print the various subcommands and options available to you. To see more information about a specific subcommand, run: + +```bash +./stump --help +``` + +## Examples + +### Locking a user account + +To lock a user account, run: + +```bash +./stump account lock --username +``` + +### Unlocking a user account + +To unlock a user account, run: + +```bash +./stump account unlock --username +``` + +### Resetting a user's password + +To reset a user's password, run: + +```bash +./stump account reset-password --username +``` + +You will be prompted to enter a new password, with a confirmation prompt to ensure you entered it correctly. The password will be hashed and salted and stored in the database to replace the existing one. diff --git a/apps/docs/pages/guides/configuration.md b/apps/docs/pages/guides/configuration.md index 4c61357c0..ec0deab3e 100644 --- a/apps/docs/pages/guides/configuration.md +++ b/apps/docs/pages/guides/configuration.md @@ -32,13 +32,13 @@ The path to the PDFium binary. This is only required if you want PDF support and | ------ | ----------------------------- | | String | `/lib/libpdfium.so` in Docker | -### SESSION_SECRET +### SESSION_EXPIRY_CLEANUP_INTERVAL -The secret key used to sign session cookies. This should be a random string of characters. If you don't set this, Stump will generate a random string for you. Only set this if you want to use a specific secret key. +The time (_in seconds_) between each session expiry cleanup check. The check will remove any expired sessions from the database. -| Type | Default Value | -| ------ | -------------------------- | -| String | _Randomly generated value_ | +| Type | Default Value | +| ------- | ------------- | +| Integer | `60` | ### SESSION_TTL diff --git a/apps/docs/pages/guides/readers.mdx b/apps/docs/pages/guides/readers.mdx index 10251996f..ef3f5642f 100644 --- a/apps/docs/pages/guides/readers.mdx +++ b/apps/docs/pages/guides/readers.mdx @@ -8,7 +8,7 @@ Stump has a few different built-in readers that you can use to read your books. - **Image-based reader**: A reader for reading image based books. These are valid archive files, such as `.cbz`/`zip` and `.cbr`/`rar` files. This reader also comes in two flavors: - **Page-based reader**: A reader that displays each page one at a time. - **Continuous scroll reader**: A reader that displays the entire book as one long page, allowing you to scroll through it either vertically or horizontally. Images are virtualized, so you can scroll through a book with hundreds of pages without any performance issues. **Not yet implemented!** -- **PDF reader**: A reader for reading PDF files. **Not yet implemented!** +- **PDF reader**: A reader for reading PDF files. ## Ebook reader diff --git a/apps/docs/pages/guides/rest-api.md b/apps/docs/pages/guides/rest-api.md index 9d593db12..6466976aa 100644 --- a/apps/docs/pages/guides/rest-api.md +++ b/apps/docs/pages/guides/rest-api.md @@ -2,16 +2,20 @@ Stump exposes a REST API that allows you to interact with your Stump server. -## Accessing Swagger UI +## Authentication -Stump's REST API is documented using Swagger. You can access Swagger UI by visiting visiting `http(s)://your-server(:10801)/swagger-ui`. If you aren't familiar with Swagger, you can read more about it [here](https://swagger.io/). Under the hood, Stump uses [utoipa](https://github.com/juhaku/utoipa) for semi-automated Swagger generation. If you find any issues or inconsistencies with the API options available while using the Swagger UI, please open an [issue](https://github.com/stumpapp/stump/issues) outlining the problem. +### Sessions -### Disabling Swagger UI +Stump uses server-side sessions to authenticate users. These sessions are stored in the database, and are automatically cleaned up within 60 seconds of expiring. You can change the expiry cleanup check interval by setting the `SESSION_EXPIRY_CLEANUP_INTERVAL` environment variable. See the [configuration guide](/guides/configuration) for more information. -If you don't want to expose Swagger UI, you can disable it by setting the `ENABLE_SWAGGER_UI` environment variable to `false`. See the [configuration guide](/guides/configuration) for more information. +### Basic Authentication -## Authentication +Stump supports [Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) in order to properly support OPDS clients. Authenticating using this method will still create a server-side session for you. -Stump uses server-side sessions to authenticate users. The only exception to this rule are the OPDS endpoints, which may also use Basic Authentication. This was done to support the wide range of OPDS clients that authenticate using Basic Authentication. +## Swagger UI -Authenticating with Basic Authentication will still create a server-side session for you. +Stump's REST API is documented using Swagger. You can access Swagger UI by visiting visiting `http(s)://your-server(:10801)/swagger-ui`. If you aren't familiar with Swagger, you can read more about it [here](https://swagger.io/). Under the hood, Stump uses [utoipa](https://github.com/juhaku/utoipa) for semi-automated Swagger generation. If you find any issues or inconsistencies with the API options available while using the Swagger UI, please open an [issue](https://github.com/stumpapp/stump/issues) outlining the problem. + +### Disabling Swagger UI + +If you don't want to expose Swagger UI, you can disable it by setting the `ENABLE_SWAGGER_UI` environment variable to `false`. See the [configuration guide](/guides/configuration) for more information. diff --git a/apps/docs/pages/guides/user-management.md b/apps/docs/pages/guides/user-management.md deleted file mode 100644 index 9ae9f78e8..000000000 --- a/apps/docs/pages/guides/user-management.md +++ /dev/null @@ -1,33 +0,0 @@ -# Users and User Accounts - -Stump has two user account types: - -- **Server Owner**: The owner of the server. This user has full control over the server, and can add and remove users. -- **Server Member**: A user that is granted access to the server. This user has majority read-only access to the server. - -An 'unclaimed' Stump server, or a server that has no user with the `server owner` role, will prompt for an initialization step, and will automatically assign the first registered user the **Server Owner** role. - -All of the user management functionality is available in the `Users` section of the settings page, which only the **Server Owner** has access to, available at `/settings/users` in your browser. The following sections will cover the various user management features. - -## Creating a user - -To create a new user, click the `Create user` button in the `Users` section of the settings page. This will open a modal with the following fields: - -- **Username**: The username of the user. This is used to log in to the server. -- **Password**: The password of the user. This is used to log in to the server. You can click the `Generate` button to generate a random password, or manually enter one. -- **Age restriction**: The _optional_ age restriction of the user. This is used to determine which books the user can access. You may enter a number corresponding to the **maximum** age rating the user can access. For example, if you enter `13` then the user will be able to access books with an age rating of `13` or lower. See the [age restrictions](/guides/access-control#age-restrictions) section of the Access Control guide for more information. - - - **Note**: If you check the `Enforce restrictions for missing metadata` checkbox, then the user will only be able to access books that: - - 1. _Explicitly_ have an age rating set - 2. The age rating is less than or equal to the user's age restriction. - - Otherwise, the user will be able to access books that _do not_ have an age rating set. - -## Editing a user - -> Such empty! Will be filled in the future. - -## Deleting a user - -> Such empty! Will be filled in the future. diff --git a/apps/docs/pages/guides/user-management.mdx b/apps/docs/pages/guides/user-management.mdx new file mode 100644 index 000000000..c053f3b1b --- /dev/null +++ b/apps/docs/pages/guides/user-management.mdx @@ -0,0 +1,82 @@ +import { Callout } from 'nextra-theme-docs' + +# Users and user accounts + + + All functionality outlined here is currently only available to server owners. There is a + role-based access control system in the works, which will allow server owners to grant access to + pieces of this functionality to other users. A lot of this information will change when that is + released. + + +Stump has two user account types: + +- **Server Owner**: The owner of the server. This user has full control over the server, and can add and remove users. +- **Server Member**: A user that is granted access to the server. This user has majority read-only access to the server. + +An 'unclaimed' Stump server, or a server that has no user with the `server owner` role, will prompt for an initialization step, and will automatically assign the first registered user the **Server Owner** role. + +All of the user management functionality is available in the `Users` section of the settings page, which only the **Server Owner** has access to, available at `/settings/users` in your browser. The following sections will cover the various user management features. + +## User management + +### Creating a user + +To create a new user, click the `Create user` button in the `Users` section of the settings page. This will open a modal with the following fields: + +- **Username**: The username of the user. This is used to log in to the server. +- **Password**: The password of the user. This is used to log in to the server. You can click the `Generate` button to generate a random password, or manually enter one. +- **Age restriction**: The _optional_ age restriction of the user. This is used to determine which books the user can access. You may enter a number corresponding to the **maximum** age rating the user can access. For example, if you enter `13` then the user will be able to access books with an age rating of `13` or lower. See the [age restrictions](/guides/access-control#age-restrictions) section of the Access Control guide for more information. + + - **Note**: If you check the `Enforce restrictions for missing metadata` checkbox, then the user will only be able to access books that: + + 1. _Explicitly_ have an age rating set + 2. The age rating is less than or equal to the user's age restriction. + + Otherwise, the user will be able to access books that _do not_ have an age rating set. + +### Editing a user + +> Such empty! Will be filled in the future. + +### Deleting a user + +To delete a user, navigate to `/settings/users` in your browser. Locate the `Users` table and click the action menu button (three dots) for the user you wish to delete. Click the `Delete` button in the action menu. This will open a modal asking you to confirm the deletion. Click the `Delete` button in the modal to confirm the deletion. + +## Security + +Stump currently does not enforce any password complexity requirements. This can change if there is enough demand for it. In general, Stump follows a fairly standard security model: + +- Stored passwords are hashed and salted +- All ASCII/Unicode characters are allowed +- There are no knowledge-based authentication (KBA) recovery options, such as “What was the name of your first pet?” +- Users are allowed 10 failed password attempts before being locked out completely (until an administrator unlocks the account) + +### Account locking and unlocking + +If a user has been locked out of their account, it is up to the server owner to unlock the account to restore access. This can be done using either of the following methods: + +1. Navigate to `/settings/users` in your browser. Locate the `Users` table and click the action menu button (three dots) for the user you wish to unlock. Click the `Unlock` button in the action menu. This will open a modal asking you to confirm the unlock. Click the `Unlock` button in the modal to confirm the unlock. +2. Use the embedded CLI in the Stump server to unlock the user account. See the [CLI](/guides/cli) guide for more information. In general, the command will look like this: + + ```bash + ./stump account unlock --username + ``` + + Similarly, you can lock a user account using the following command: + + ```bash + ./stump account lock --username + ``` + +In the event the server owner account becomes locked, you will only be able to unlock it using the CLI. + +### Password reset + +If a user has forgotten their password, you will have to use the embedded CLI in the Stump server to reset the user's password. See the [CLI](/guides/cli) guide for more information. In general, the command will look like this: + +```bash +./stump account reset-password --username +``` + +It will prompt you for a new password with confirmation. Once the password has been reset, the user will be able to log in with the new password. diff --git a/apps/server/src/routers/api/v1/auth.rs b/apps/server/src/routers/api/v1/auth.rs index dd92adfca..6378c2473 100644 --- a/apps/server/src/routers/api/v1/auth.rs +++ b/apps/server/src/routers/api/v1/auth.rs @@ -110,7 +110,7 @@ async fn login( let client = state.db.clone(); let today: DateTime = Utc::now().into(); - // TODO: make this configuration option via environment variable + // TODO: make this configurable via environment variable so knowledgable attackers can't bypass this let twenty_four_hours_ago = today - Duration::hours(24); let fetch_result = client @@ -146,10 +146,12 @@ async fn login( let user_id = db_user.id.clone(); let matches = verify_password(&db_user.hashed_password, &input.password)?; if !matches { + // TODO: make this configurable via environment variable so knowledgable attackers can't bypass this let should_lock_account = db_user .login_activity .as_ref() - .map(|activity| activity.len() >= 9) + // If there are 9 or more failed login attempts _in a row_, within a 24 hour period, lock the account + .map(|activity| activity.iter().find(|activity| activity.authentication_successful).is_none() && activity.len() >= 9) .unwrap_or(false); handle_login_attempt(&client, db_user, user_agent, request_info, false) diff --git a/core/src/filesystem/image/process.rs b/core/src/filesystem/image/process.rs index f82b65864..a721c0cda 100644 --- a/core/src/filesystem/image/process.rs +++ b/core/src/filesystem/image/process.rs @@ -90,6 +90,7 @@ pub struct ImageProcessorOptions { // I would like to iterate after the initial release to make this more robust so that these choices // are stored in the database /// The page to use when generating an image. This is not applicable to all media formats. + #[specta(optional)] pub page: Option, } diff --git a/packages/interface/src/components/table/Table.tsx b/packages/interface/src/components/table/Table.tsx index 0a2713ec3..81560731f 100644 --- a/packages/interface/src/components/table/Table.tsx +++ b/packages/interface/src/components/table/Table.tsx @@ -110,60 +110,68 @@ export default function Table({ const tableRows = table.getRowModel().rows return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - ) - })} - - ))} - - - {tableRows.map((row) => { - return ( - - {row.getVisibleCells().map((cell) => { + <> +
+
-
- - {flexRender(header.column.columnDef.header, header.getContext())} - - {sortable && ( - - )} -
-
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { return ( - + ) })} - ) - })} - {tableRows.length === 0 && emptyRenderer && ( - - - - )} - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - +
+ + {flexRender(header.column.columnDef.header, header.getContext())} + + {sortable && ( + + )} +
+
{emptyRenderer()}
-
+ ))} + + + {tableRows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) + })} + {tableRows.length === 0 && emptyRenderer && ( + + {emptyRenderer()} + + )} + + +
- + {tableRows.length > 0 ? ( <> @@ -208,7 +216,7 @@ export default function Table({ onChangePage={handlePageChanged} />
-
+ ) } diff --git a/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx b/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx index 52b2cecf1..295dc63ab 100644 --- a/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx +++ b/packages/interface/src/scenes/auth/LoginOrClaimScene.tsx @@ -114,7 +114,9 @@ export default function LoginOrClaimScene() { if (!loginError) return null // If the response is a 403, and we are NOT claiming, it is likely because - // the account login is disabled (i.e. the account is locked). + // the account login is disabled (i.e. the account is locked). Additionally, + // authentication had to have passed, otherwise we would have gotten a 401. So, + // we can safely display the error message from the server. if (isAxiosError(loginError) && loginError.response?.status === 403) { const message = loginError.response?.data as string return ( diff --git a/packages/interface/src/scenes/settings/job/DeleteAllSection.tsx b/packages/interface/src/scenes/settings/job/DeleteAllSection.tsx index 593c35bb8..4584c0641 100644 --- a/packages/interface/src/scenes/settings/job/DeleteAllSection.tsx +++ b/packages/interface/src/scenes/settings/job/DeleteAllSection.tsx @@ -36,7 +36,7 @@ export default function DeleteAllSection() { return ( - + {t('settingsScene.jobs.historyTable.deleteAllMessage')}
diff --git a/packages/interface/src/scenes/settings/user/login-activity/ClearActivitySection.tsx b/packages/interface/src/scenes/settings/user/login-activity/ClearActivitySection.tsx index fc346869b..e6fda9e0d 100644 --- a/packages/interface/src/scenes/settings/user/login-activity/ClearActivitySection.tsx +++ b/packages/interface/src/scenes/settings/user/login-activity/ClearActivitySection.tsx @@ -32,7 +32,7 @@ export default function ClearActivitySection() { return ( - + Login activity can be cleared and deleted from the database at any time. diff --git a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx index ecd8100ad..3579bebd9 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx @@ -19,7 +19,7 @@ const columnHelper = createColumnHelper() const baseColumns = [ columnHelper.accessor('username', { cell: ({ row: { original: user } }) => , - header: 'Username', + header: 'User', }), columnHelper.accessor('role', { cell: (info) => ( From eb2c52872240ebbdc9e1e0fcbfc3ec31c90c4046 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 3 Oct 2023 21:41:08 -0700 Subject: [PATCH 3/4] clean up sessions after account lock, add migration --- apps/server/src/middleware/auth.rs | 8 +- apps/server/src/routers/api/v1/auth.rs | 2 +- apps/server/src/routers/api/v1/filesystem.rs | 4 +- apps/server/src/routers/api/v1/library.rs | 12 +-- apps/server/src/routers/api/v1/log.rs | 6 +- apps/server/src/routers/api/v1/media.rs | 4 +- apps/server/src/routers/api/v1/series.rs | 4 +- apps/server/src/routers/api/v1/user.rs | 83 +++++++++++++++++-- apps/server/src/routers/utoipa.rs | 1 + apps/server/src/utils/auth.rs | 4 +- apps/server/src/utils/filter.rs | 1 + .../20231004002306_sessions/migration.sql | 9 ++ core/src/db/entity/user.rs | 6 ++ crates/cli/src/commands/account.rs | 18 +++- packages/api/src/user.ts | 8 ++ .../settings/user/UserManagementScene.tsx | 1 + .../user/user-table/UserActionMenu.tsx | 20 ++++- .../settings/user/user-table/UserTable.tsx | 10 +++ packages/types/core.ts | 4 +- 19 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 core/prisma/migrations/20231004002306_sessions/migration.sql diff --git a/apps/server/src/middleware/auth.rs b/apps/server/src/middleware/auth.rs index 9cd04e3d1..561feae12 100644 --- a/apps/server/src/middleware/auth.rs +++ b/apps/server/src/middleware/auth.rs @@ -39,13 +39,7 @@ where } let state = AppState::from_ref(state); - // let session = parts.extensions.get::().ok_or_else(|| { - // ( - // StatusCode::INTERNAL_SERVER_ERROR, - // "Failed to extract session handle", - // ) - // .into_response() - // })?; + let session = Session::from_request_parts(parts, &state) .await .map_err(|e| { diff --git a/apps/server/src/routers/api/v1/auth.rs b/apps/server/src/routers/api/v1/auth.rs index 6378c2473..5d1ec1f02 100644 --- a/apps/server/src/routers/api/v1/auth.rs +++ b/apps/server/src/routers/api/v1/auth.rs @@ -151,7 +151,7 @@ async fn login( .login_activity .as_ref() // If there are 9 or more failed login attempts _in a row_, within a 24 hour period, lock the account - .map(|activity| activity.iter().find(|activity| activity.authentication_successful).is_none() && activity.len() >= 9) + .map(|activity| !activity.iter().any(|activity| activity.authentication_successful) && activity.len() >= 9) .unwrap_or(false); handle_login_attempt(&client, db_user, user_agent, request_info, false) diff --git a/apps/server/src/routers/api/v1/filesystem.rs b/apps/server/src/routers/api/v1/filesystem.rs index 9e9dff226..017152822 100644 --- a/apps/server/src/routers/api/v1/filesystem.rs +++ b/apps/server/src/routers/api/v1/filesystem.rs @@ -19,7 +19,7 @@ use crate::{ config::state::AppState, errors::{ApiError, ApiResult}, middleware::auth::{AdminGuard, Auth}, - utils::get_session_admin_user, + utils::get_session_server_owner_user, }; pub(crate) fn mount(app_state: AppState) -> Router { @@ -52,7 +52,7 @@ pub async fn list_directory( pagination: Query, input: Json>, ) -> ApiResult>> { - let _ = get_session_admin_user(&session)?; + let _ = get_session_server_owner_user(&session)?; let input = input.0.unwrap_or_default(); let start_path = input.path.unwrap_or_else(|| { diff --git a/apps/server/src/routers/api/v1/library.rs b/apps/server/src/routers/api/v1/library.rs index 54e706710..bff213be9 100644 --- a/apps/server/src/routers/api/v1/library.rs +++ b/apps/server/src/routers/api/v1/library.rs @@ -48,7 +48,7 @@ use crate::{ errors::{ApiError, ApiResult}, middleware::auth::Auth, utils::{ - chain_optional_iter, decode_path_filter, get_session_admin_user, + chain_optional_iter, decode_path_filter, get_session_server_owner_user, get_session_user, http::ImageResponse, FilterableQuery, LibraryBaseFilter, LibraryFilter, LibraryRelationFilter, MediaFilter, SeriesFilter, }, @@ -560,7 +560,7 @@ async fn patch_library_thumbnail( session: Session, Json(body): Json, ) -> ApiResult { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let client = ctx.get_db(); @@ -757,7 +757,7 @@ async fn scan_library( session: Session, ) -> Result<(), ApiError> { let db = ctx.get_db(); - let _user = get_session_admin_user(&session)?; + let _user = get_session_server_owner_user(&session)?; let library = db .library() @@ -797,7 +797,7 @@ async fn create_library( State(ctx): State, Json(input): Json, ) -> ApiResult> { - let user = get_session_admin_user(&session)?; + let user = get_session_server_owner_user(&session)?; let db = ctx.get_db(); debug!(user_id = user.id, ?input, "Creating library"); @@ -922,7 +922,7 @@ async fn update_library( Path(id): Path, Json(input): Json, ) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let db = ctx.get_db(); if !path::Path::new(&input.path).exists() { @@ -1034,7 +1034,7 @@ async fn delete_library( Path(id): Path, State(ctx): State, ) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let db = ctx.get_db(); trace!(?id, "Attempting to delete library"); diff --git a/apps/server/src/routers/api/v1/log.rs b/apps/server/src/routers/api/v1/log.rs index 1bb79a42c..b0804f654 100644 --- a/apps/server/src/routers/api/v1/log.rs +++ b/apps/server/src/routers/api/v1/log.rs @@ -21,7 +21,7 @@ use crate::{ errors::{ApiError, ApiResult}, middleware::auth::Auth, routers::sse::stream_shutdown_guard, - utils::get_session_admin_user, + utils::get_session_server_owner_user, }; pub(crate) fn mount(app_state: AppState) -> Router { @@ -132,7 +132,7 @@ async fn tail_log_file() -> Sse>> { /// Get information about the Stump log file, located at STUMP_CONFIG_DIR/Stump.log, or /// ~/.stump/Stump.log by default. Information such as the file size, last modified date, etc. async fn get_logfile_info(session: Session) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let log_file_path = get_log_file(); let file = File::open(log_file_path.as_path())?; @@ -165,7 +165,7 @@ async fn get_logfile_info(session: Session) -> ApiResult> { // this route *WILL* delete all of the file contents. // #[delete("/logs")] async fn clear_logs(session: Session) -> ApiResult<()> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let log_file_path = get_log_file(); File::create(log_file_path.as_path())?; diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index d6efa8d5c..ef3566b4e 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -35,7 +35,7 @@ use crate::{ errors::{ApiError, ApiResult}, middleware::auth::Auth, utils::{ - chain_optional_iter, decode_path_filter, get_session_admin_user, + chain_optional_iter, decode_path_filter, get_session_server_owner_user, get_session_user, http::{ImageResponse, NamedFile}, FilterableQuery, MediaBaseFilter, MediaFilter, MediaRelationFilter, ReadStatus, @@ -899,7 +899,7 @@ async fn patch_media_thumbnail( session: Session, Json(body): Json, ) -> ApiResult { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let client = ctx.get_db(); diff --git a/apps/server/src/routers/api/v1/series.rs b/apps/server/src/routers/api/v1/series.rs index e3a49881a..a55e92d5a 100644 --- a/apps/server/src/routers/api/v1/series.rs +++ b/apps/server/src/routers/api/v1/series.rs @@ -40,7 +40,7 @@ use crate::{ errors::{ApiError, ApiResult}, middleware::auth::Auth, utils::{ - chain_optional_iter, decode_path_filter, get_session_admin_user, + chain_optional_iter, decode_path_filter, get_session_server_owner_user, get_session_user, http::ImageResponse, FilterableQuery, SeriesBaseFilter, SeriesFilter, SeriesQueryRelation, SeriesRelationFilter, }, @@ -525,7 +525,7 @@ async fn patch_series_thumbnail( session: Session, Json(body): Json, ) -> ApiResult { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let client = ctx.get_db(); diff --git a/apps/server/src/routers/api/v1/user.rs b/apps/server/src/routers/api/v1/user.rs index fe2aba7f5..04558c50f 100644 --- a/apps/server/src/routers/api/v1/user.rs +++ b/apps/server/src/routers/api/v1/user.rs @@ -6,6 +6,7 @@ use axum::{ }; use axum_extra::extract::Query; use prisma_client_rust::{chrono::Utc, Direction}; +use serde::Deserialize; use stump_core::{ db::{ entity::{ @@ -14,16 +15,19 @@ use stump_core::{ }, query::pagination::{Pageable, Pagination, PaginationQuery}, }, - prisma::{user, user_login_activity, user_preferences, PrismaClient}, + prisma::{session, user, user_login_activity, user_preferences, PrismaClient}, }; use tower_sessions::Session; use tracing::{debug, trace}; +use utoipa::ToSchema; use crate::{ config::{session::SESSION_USER_KEY, state::AppState}, errors::{ApiError, ApiResult}, middleware::auth::Auth, - utils::{get_hash_cost, get_session_admin_user, get_session_user, UserQueryRelation}, + utils::{ + get_hash_cost, get_session_server_owner_user, get_session_user, UserQueryRelation, + }, }; use super::auth::LoginOrRegisterArgs; @@ -50,6 +54,7 @@ pub(crate) fn mount(app_state: AppState) -> Router { .put(update_user_handler) .delete(delete_user_by_id), ) + .route("/lock", put(update_user_lock_status)) .route("/login-activity", get(get_user_login_activity_by_id)) .route( "/preferences", @@ -105,7 +110,7 @@ async fn get_users( pagination_query: Query, session: Session, ) -> ApiResult>>> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let pagination = pagination_query.0.get(); let is_unpaged = pagination.is_unpaged(); @@ -115,6 +120,7 @@ async fn get_users( let include_user_read_progress = relation_query.include_read_progresses.unwrap_or_default(); + let include_session_count = relation_query.include_session_count.unwrap_or_default(); let (users, count) = ctx .db @@ -126,6 +132,10 @@ async fn get_users( query = query.with(user::read_progresses::fetch(vec![])); } + if include_session_count { + query = query.with(user::sessions::fetch(vec![])); + } + if !is_unpaged { query = apply_pagination(query, &pagination_cloned); } @@ -168,7 +178,7 @@ async fn get_user_login_activity( State(ctx): State, session: Session, ) -> ApiResult>> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let client = ctx.get_db(); @@ -200,7 +210,7 @@ async fn delete_user_login_activity( State(ctx): State, session: Session, ) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let client = ctx.get_db(); @@ -284,7 +294,7 @@ async fn create_user( State(ctx): State, Json(input): Json, ) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let db = ctx.get_db(); let hashed_password = bcrypt::hash(input.password, get_hash_cost())?; let created_user = db @@ -418,7 +428,7 @@ async fn delete_user_by_id( Json(input): Json, ) -> ApiResult> { let db = ctx.get_db(); - let user = get_session_admin_user(&session)?; + let user = get_session_server_owner_user(&session)?; if user.id == id { return Err(ApiError::BadRequest( @@ -466,7 +476,7 @@ async fn get_user_by_id( State(ctx): State, session: Session, ) -> ApiResult> { - get_session_admin_user(&session)?; + get_session_server_owner_user(&session)?; let db = ctx.get_db(); let user_by_id = db .user() @@ -567,6 +577,63 @@ async fn update_user_handler( Ok(Json(updated_user)) } +#[derive(Deserialize, ToSchema)] +pub struct UpdateAccountLock { + lock: bool, +} + +#[utoipa::path( + put, + path = "/api/v1/users/:id/lock", + tag = "user", + params( + ("id" = String, Path, description = "The user's ID.", example = "1ab2c3d4") + ), + request_body = UpdateAccountLock, + responses( + (status = 200, description = "Successfully updated user lock status.", body = User), + (status = 400, description = "You cannot lock your own account."), + (status = 401, description = "Unauthorized."), + (status = 403, description = "Forbidden."), + (status = 500, description = "Internal server error."), + ) +)] +async fn update_user_lock_status( + Path(id): Path, + State(ctx): State, + session: Session, + Json(input): Json, +) -> ApiResult> { + let user = get_session_server_owner_user(&session)?; + if user.id == id { + return Err(ApiError::BadRequest( + "You cannot lock your own account.".into(), + )); + } + + let db = ctx.get_db(); + let updated_user = db + .user() + .update( + user::id::equals(id.clone()), + vec![user::is_locked::set(input.lock)], + ) + .exec() + .await?; + + if input.lock { + // Delete all sessions for this user if they are being locked + let removed_sessions = db + .session() + .delete_many(vec![session::user_id::equals(id)]) + .exec() + .await?; + tracing::trace!(?removed_sessions, "Removed sessions for locked user"); + } + + Ok(Json(User::from(updated_user))) +} + #[utoipa::path( get, path = "/api/v1/users/:id/preferences", diff --git a/apps/server/src/routers/utoipa.rs b/apps/server/src/routers/utoipa.rs index f1c3ee32f..5a232dbba 100644 --- a/apps/server/src/routers/utoipa.rs +++ b/apps/server/src/routers/utoipa.rs @@ -101,6 +101,7 @@ use super::api::{ api::v1::user::update_user_handler, api::v1::user::get_user_preferences, api::v1::user::update_user_preferences, + api::v1::user::update_user_lock_status ), components( schemas( diff --git a/apps/server/src/utils/auth.rs b/apps/server/src/utils/auth.rs index 824bc709e..6fbcdc599 100644 --- a/apps/server/src/utils/auth.rs +++ b/apps/server/src/utils/auth.rs @@ -46,10 +46,10 @@ pub fn get_session_user(session: &Session) -> ApiResult { } } -pub fn get_session_admin_user(session: &Session) -> ApiResult { +pub fn get_session_server_owner_user(session: &Session) -> ApiResult { let user = get_session_user(session)?; - if user.is_admin() { + if user.is_server_owner() { Ok(user) } else { Err(ApiError::Forbidden( diff --git a/apps/server/src/utils/filter.rs b/apps/server/src/utils/filter.rs index e75577815..ee5f95965 100644 --- a/apps/server/src/utils/filter.rs +++ b/apps/server/src/utils/filter.rs @@ -253,6 +253,7 @@ pub struct SeriesQueryRelation { #[derive(Default, Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct UserQueryRelation { pub include_read_progresses: Option, + pub include_session_count: Option, } // TODO: decide what others to include diff --git a/core/prisma/migrations/20231004002306_sessions/migration.sql b/core/prisma/migrations/20231004002306_sessions/migration.sql new file mode 100644 index 000000000..e7462bb56 --- /dev/null +++ b/core/prisma/migrations/20231004002306_sessions/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "data" BLOB NOT NULL, + "user_id" TEXT NOT NULL, + CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/core/src/db/entity/user.rs b/core/src/db/entity/user.rs index 46a44f343..1da63f5ba 100644 --- a/core/src/db/entity/user.rs +++ b/core/src/db/entity/user.rs @@ -27,6 +27,9 @@ pub struct User { pub last_login: Option, pub is_locked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub login_sessions_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub user_preferences: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -100,6 +103,8 @@ impl From for User { .login_activity() .map(|la| la.clone().into_iter().map(LoginActivity::from).collect()) .ok(); + let login_sessions_count = + data.sessions().map(|sessions| sessions.len() as i32).ok(); User { id: data.id, @@ -113,6 +118,7 @@ impl From for User { last_login: data.last_login.map(|dt| dt.to_rfc3339()), login_activity, is_locked: data.is_locked, + login_sessions_count, } } } diff --git a/crates/cli/src/commands/account.rs b/crates/cli/src/commands/account.rs index c35ca7a55..63a23a422 100644 --- a/crates/cli/src/commands/account.rs +++ b/crates/cli/src/commands/account.rs @@ -2,7 +2,10 @@ use std::{thread, time::Duration}; use clap::Subcommand; use dialoguer::{theme::ColorfulTheme, Password}; -use stump_core::{db::create_client, prisma::user}; +use stump_core::{ + db::create_client, + prisma::{session, user}, +}; use crate::{commands::chain_optional_iter, error::CliResult, CliConfig, CliError}; @@ -64,12 +67,23 @@ async fn set_account_lock_status(username: String, lock: bool) -> CliResult<()> let affected_rows = client .user() .update_many( - vec![user::username::equals(username)], + vec![user::username::equals(username.clone())], vec![user::is_locked::set(lock)], ) .exec() .await?; + if lock { + progress.set_message("Removing active login sessions..."); + client + .session() + .delete_many(vec![session::user::is(vec![user::username::equals( + username, + )])]) + .exec() + .await?; + } + thread::sleep(Duration::from_millis(500)); if affected_rows == 0 { diff --git a/packages/api/src/user.ts b/packages/api/src/user.ts index 4577320c8..738a0582f 100644 --- a/packages/api/src/user.ts +++ b/packages/api/src/user.ts @@ -78,6 +78,12 @@ export function deleteAllLoginActivity(): Promise> { return API.delete(`/users/login-activity`) } +export function setLockStatus(userId: string, lock: boolean): Promise> { + return API.put(`/users/${userId}/lock`, { + lock, + }) +} + export const userApi = { createUser, deleteAllLoginActivity, @@ -86,6 +92,7 @@ export const userApi = { getLoginActivityForUser, getUserPreferences, getUsers, + setLockStatus, updatePreferences, updateUser, updateUserPreferences, @@ -100,6 +107,7 @@ export const userQueryKeys: Record = { getLoginActivityForUser: 'user.getLoginActivityForUser', getUserPreferences: 'user.getUserPreferences', getUsers: 'user.getUsers', + setLockStatus: 'user.setLockStatus', updatePreferences: 'user.updatePreferences', updateUser: 'user.updateUser', updateUserPreferences: 'user.updateUserPreferences', diff --git a/packages/interface/src/scenes/settings/user/UserManagementScene.tsx b/packages/interface/src/scenes/settings/user/UserManagementScene.tsx index 753ff6600..b231201f8 100644 --- a/packages/interface/src/scenes/settings/user/UserManagementScene.tsx +++ b/packages/interface/src/scenes/settings/user/UserManagementScene.tsx @@ -36,6 +36,7 @@ export default function UserManagementScene() { page_size: pagination.pageSize, params: { include_read_progresses: true, + include_session_count: true, }, }) diff --git a/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx b/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx index 715afdc55..50aa34106 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx @@ -1,7 +1,10 @@ +import { userApi, userQueryKeys } from '@stump/api' +import { invalidateQueries } from '@stump/client' import { DropdownMenu, IconButton } from '@stump/components' import { User } from '@stump/types' import { Lock, MoreVertical, Pencil, Trash, Unlock } from 'lucide-react' import React, { useMemo } from 'react' +import toast from 'react-hot-toast' import { useAppContext } from '../../../../context.ts' import { noop } from '../../../../utils/misc.ts' @@ -17,6 +20,20 @@ export default function UserActionMenu({ user }: Props) { const isSelf = byUser?.id === user.id + const handleSetLockStatus = async (lock: boolean) => { + try { + await userApi.setLockStatus(user.id, lock) + await invalidateQueries({ keys: [userQueryKeys.getUsers] }) + } catch (error) { + if (error instanceof Error) { + toast.error(error.message) + } else { + console.error(error) + toast.error('An unknown error occurred') + } + } + } + const items = useMemo( () => [ { @@ -39,9 +56,10 @@ export default function UserActionMenu({ user }: Props) { ) : ( ), - onClick: noop, + onClick: () => handleSetLockStatus(!user.is_locked), }, ], + // eslint-disable-next-line react-hooks/exhaustive-deps [setDeletingUser, user, isSelf], ) diff --git a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx index 3579bebd9..c94d674a6 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx @@ -53,6 +53,15 @@ const baseColumns = [ ), header: 'Last login', }), + columnHelper.display({ + cell: ({ row: { original } }) => ( + + {original.login_sessions_count} + + ), + header: 'Active sessions', + id: 'login_sessions_count', + }), columnHelper.display({ cell: ({ row: { original } }) => ( @@ -69,6 +78,7 @@ const baseColumns = [
), id: 'actions', + size: 28, }), ] diff --git a/packages/types/core.ts b/packages/types/core.ts index e2fbe3e4c..2e56b3be2 100644 --- a/packages/types/core.ts +++ b/packages/types/core.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-types */ // DO NOT MODIFY THIS FILE, IT IS AUTOGENERATED -export type User = { id: string; username: string; role: string; avatar_url: string | null; created_at: string; last_login: string | null; is_locked: boolean; user_preferences?: UserPreferences | null; login_activity?: LoginActivity[] | null; age_restriction?: AgeRestriction | null; read_progresses?: ReadProgress[] | null } +export type User = { id: string; username: string; role: string; avatar_url: string | null; created_at: string; last_login: string | null; is_locked: boolean; login_sessions_count?: number | null; user_preferences?: UserPreferences | null; login_activity?: LoginActivity[] | null; age_restriction?: AgeRestriction | null; read_progresses?: ReadProgress[] | null } export type UserRole = "SERVER_OWNER" | "MEMBER" @@ -93,7 +93,7 @@ export type ImageFormat = "Webp" | "Jpeg" | "JpegXl" | "Png" /** * Options for processing images throughout Stump. */ -export type ImageProcessorOptions = { resize_options: ImageResizeOptions | null; format: ImageFormat; quality: number | null; page: number | null } +export type ImageProcessorOptions = { resize_options: ImageResizeOptions | null; format: ImageFormat; quality: number | null; page?: number | null } export type DirectoryListing = { parent: string | null; files: DirectoryListingFile[] } From 6c75ecdeeecc488ad900fb900a26840e34a11426 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Wed, 4 Oct 2023 17:24:28 -0700 Subject: [PATCH 4/4] small fixes and updates --- apps/docs/pages/guides/filesystem-scans.md | 42 ++++++++++++------- .../server/src/config/prisma_session_store.rs | 5 +-- core/src/db/entity/library.rs | 14 +++---- .../filesystem/scanner/library_scan_job.rs | 4 +- core/src/job/scheduler.rs | 2 +- .../components/src/dropdown/primitives.tsx | 2 +- .../components/sidebar/LibraryOptionsMenu.tsx | 20 ++++----- .../interface/src/components/table/Table.tsx | 2 +- .../admins/CreateOrEditLibraryForm.tsx | 7 ++-- .../scenes/library/admins/ScanModeForm.tsx | 24 +++++------ .../src/scenes/settings/job/JobTable.tsx | 1 + .../login-activity/LoginActivityTable.tsx | 2 +- .../user/user-table/UserActionMenu.tsx | 2 +- .../settings/user/user-table/UserTable.tsx | 12 +++++- packages/types/core.ts | 2 +- 15 files changed, 81 insertions(+), 60 deletions(-) diff --git a/apps/docs/pages/guides/filesystem-scans.md b/apps/docs/pages/guides/filesystem-scans.md index 488145af7..2844e1fa2 100644 --- a/apps/docs/pages/guides/filesystem-scans.md +++ b/apps/docs/pages/guides/filesystem-scans.md @@ -1,31 +1,43 @@ # Scanning -Scanning is essential for keeping your media libraries up-to-date in Stump. Scanners will index your filesystem based on the configured libraries to detect new media files and file changes/updates, which are then synced with Stump's database. +A scan is the process of indexing your filesystem to detect new media files and file changes/updates. Scans are essential for keeping your media libraries up-to-date in Stump. -You can start scans at either the library level or the series level, which are referred to as library scan and series scan respectively throughout Stump. There are no real differences between the two, except that a library scan will scan all series in the library, while a series scan will only scan the selected series. +You can start scans at either the library level or the series level, which are referred to as `library_scan` and `series_scan`, respectively, throughout Stump. There are no real differences between the two, except that a library scan will scan all series in the library, while a series scan will only scan the selected series. -## Scan Modes +## Quick scan vs default scan -You can choose between two scanning modes: in-order and parallel scanning. +In Stump, there is the concept of a quick scan and a default scan. A quick scan, as the name suggests, is a faster scan that utilizes more concurrency and parallelism to scan your filesystem. A quick scan waits until the _very_ end to insert what changes it has detected into the database, all in one batch. -### In-order scanning +A default scan, on the other hand, is a slightly slower scan that utilizes less concurrency and parallelism to achieve a more consistent and stable scan. Changes are inserted into the database as soon as they are detected, which means that you can access new media files as soon as they are scanned. -In-order scanning processes one series at a time and inserts its media one-by-one as soon as they are discovered. This means that you can access new media files as soon as they are scanned, even if the rest of the series has not been scanned yet. +### Which one should I use? -### Parallel scanning +To preface, both options are safe and fast, they just differ in how they operate. -Parallel scanning processes multiple series at once, up to 10 series in a batch. This significantly reduces the overall scanning time, but you may not be able to access some media files until the entire batch is processed. +The benefit of a quick scan is that it is generally faster than the default. However, because it does one big batch insert at the end, you have the following caveats: -### Example +- You cannot access any new media files until the _entire_ scan is complete +- If any one of the database inserts fails, for example bad metadata being present in a media file, then the entire scan effectively fails -Let's assume you are about to add a new library to Stump. In it contains 200 series, each containing about 50 books. +Therefore, it is recommended to use a quick scan when: -An in-order scan will start from the first series and insert all of its media one at a time before moving on to the next series. On the other hand, a parallel scan will divide the 200 series into two chunks of 100 series and process up to 10 series in parallel for each chunk before inserting all the media in a single batch per chunk. +- You are running consecutive scans, i.e. not the very first scan +- Your library is not very large and the scan would complete in a reasonable amount of time -It is generally recommend to use parallel scanning whenever possible because it is more efficient and faster than in-order scanning. +Of course, if you are confident that your media files are in good shape and will not cause any issues, you are free to use whichever scan you prefer. -## Scheduled Scans +## Scheduling scans -TODO: this isn't implemented yet! +You can configure the scheduler to run scans at a specific interval. This is useful for keeping your media libraries up-to-date without having to manually run scans. -By default, Stump schedules regular automated scans of your library folders. However, you can update or disable this functionality in the server configuration section. For more information, please refer to the [relevant documentation](#). +To configure the scheduler, navigate to `/settings/jobs`, scroll to the `Job Scheduling` section towards the top of the page, fill out your desired interval (in seconds), and click the `Save scheduler changes` button. + +For convenience, there are a few preset options you may select from the dropdown menu. These are: + +- Every 6 hours (21600 seconds) +- Every 12 hours (43200 seconds) +- Every 24 hours (86400 seconds) +- Once a week (604800 seconds) +- Once a month (2592000 seconds) + +> In the future, this section of the UI will change to include scheduling options for more than just scans. However, for now, it is only for scans. diff --git a/apps/server/src/config/prisma_session_store.rs b/apps/server/src/config/prisma_session_store.rs index f66d844cb..1f8d7a968 100644 --- a/apps/server/src/config/prisma_session_store.rs +++ b/apps/server/src/config/prisma_session_store.rs @@ -93,7 +93,7 @@ impl SessionStore for PrismaSessionStore { .exec() .await?; - tracing::trace!(result = ?result, "Upserted session"); + tracing::trace!(session_id = result.id, "Upserted session"); Ok(()) } @@ -112,8 +112,7 @@ impl SessionStore for PrismaSessionStore { .await?; if let Some(result) = record { - tracing::trace!(result = ?result, "Found session"); - + tracing::trace!("Found session"); let timestamp = result.expires_at.timestamp(); let expiration_time = OffsetDateTime::from_unix_timestamp(timestamp) .expect("Failed to convert timestamp to OffsetDateTime"); diff --git a/core/src/db/entity/library.rs b/core/src/db/entity/library.rs index bbe37ef31..c3d7f273a 100644 --- a/core/src/db/entity/library.rs +++ b/core/src/db/entity/library.rs @@ -126,10 +126,10 @@ impl LibraryOptions { #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Copy, Clone, Type, ToSchema)] pub enum LibraryScanMode { - #[serde(rename = "SYNC")] - Sync, - #[serde(rename = "BATCHED")] - Batched, + #[serde(rename = "DEFAULT")] + Default, + #[serde(rename = "QUICK")] + Quick, #[serde(rename = "NONE")] None, } @@ -141,8 +141,8 @@ impl FromStr for LibraryScanMode { let uppercase = s.to_uppercase(); match uppercase.as_str() { - "SYNC" => Ok(LibraryScanMode::Sync), - "BATCHED" => Ok(LibraryScanMode::Batched), + "DEFAULT" => Ok(LibraryScanMode::Default), + "QUICK" => Ok(LibraryScanMode::Quick), "NONE" => Ok(LibraryScanMode::None), "" => Ok(LibraryScanMode::default()), _ => Err(format!("Invalid library scan mode: {}", s)), @@ -158,7 +158,7 @@ impl From for LibraryScanMode { impl Default for LibraryScanMode { fn default() -> Self { - Self::Batched + Self::Default } } diff --git a/core/src/filesystem/scanner/library_scan_job.rs b/core/src/filesystem/scanner/library_scan_job.rs index 66bb706b9..2be23d4f9 100644 --- a/core/src/filesystem/scanner/library_scan_job.rs +++ b/core/src/filesystem/scanner/library_scan_job.rs @@ -29,10 +29,10 @@ impl JobTrait for LibraryScanJob { async fn run(&mut self, ctx: WorkerCtx) -> Result { let completed_task_count = match self.scan_mode { - LibraryScanMode::Batched => { + LibraryScanMode::Quick => { batch_scanner::scan_library(ctx, self.library_path.clone()).await }, - LibraryScanMode::Sync => { + LibraryScanMode::Default => { sync_scanner::scan_library(ctx, self.library_path.clone()).await }, LibraryScanMode::None => Err(CoreError::JobInitializationError( diff --git a/core/src/job/scheduler.rs b/core/src/job/scheduler.rs index 20a0881c1..18ef5efde 100644 --- a/core/src/job/scheduler.rs +++ b/core/src/job/scheduler.rs @@ -79,7 +79,7 @@ impl JobScheduler { let library_path = library.path.clone(); let result = scheduler_ctx.dispatch_job(LibraryScanJob::new( library_path, - LibraryScanMode::Batched, + LibraryScanMode::Quick, )); if result.is_err() { tracing::error!( diff --git a/packages/components/src/dropdown/primitives.tsx b/packages/components/src/dropdown/primitives.tsx index 908bcbf96..a7d93c93b 100644 --- a/packages/components/src/dropdown/primitives.tsx +++ b/packages/components/src/dropdown/primitives.tsx @@ -160,7 +160,7 @@ const DropdownSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/packages/interface/src/components/sidebar/LibraryOptionsMenu.tsx b/packages/interface/src/components/sidebar/LibraryOptionsMenu.tsx index 953b48c64..d3eb1abee 100644 --- a/packages/interface/src/components/sidebar/LibraryOptionsMenu.tsx +++ b/packages/interface/src/components/sidebar/LibraryOptionsMenu.tsx @@ -60,24 +60,24 @@ export default function LibraryOptionsMenu({ library }: Props) { { items: [ { - label: 'Scan Library', + label: 'Scan', leftIcon: , subItems: [ { - label: 'Parallel Scan', - leftIcon: , - onClick: () => handleScan('BATCHED'), + label: 'Default', + leftIcon: , + onClick: () => handleScan('DEFAULT'), }, { - label: 'In-Order Scan', - leftIcon: , - onClick: () => handleScan('SYNC'), + label: 'Quick', + leftIcon: , + onClick: () => handleScan('QUICK'), }, ], }, { href: paths.libraryFileExplorer(library.id), - label: 'File Explorer', + label: 'File explorer', leftIcon: , }, ], @@ -86,11 +86,11 @@ export default function LibraryOptionsMenu({ library }: Props) { items: [ { href: paths.libraryManage(library.id), - label: 'Manage Library', + label: 'Manage', leftIcon: , }, { - label: 'Delete Library', + label: 'Delete', leftIcon: , onClick: () => setIsDeleting(true), }, diff --git a/packages/interface/src/components/table/Table.tsx b/packages/interface/src/components/table/Table.tsx index 81560731f..2419ec90d 100644 --- a/packages/interface/src/components/table/Table.tsx +++ b/packages/interface/src/components/table/Table.tsx @@ -128,7 +128,7 @@ export default function Table({ width: header.getSize(), }} > - + {flexRender(header.column.columnDef.header, header.getContext())} {sortable && ( diff --git a/packages/interface/src/scenes/library/admins/CreateOrEditLibraryForm.tsx b/packages/interface/src/scenes/library/admins/CreateOrEditLibraryForm.tsx index fddf0c9a9..e49e4f664 100644 --- a/packages/interface/src/scenes/library/admins/CreateOrEditLibraryForm.tsx +++ b/packages/interface/src/scenes/library/admins/CreateOrEditLibraryForm.tsx @@ -26,7 +26,7 @@ import ScanModeForm from './ScanModeForm' import ThumbnailConfigForm from './ThumbnailConfigForm' function isLibraryScanMode(input: string): input is LibraryScanMode { - return input === 'SYNC' || input === 'BATCHED' || input === 'NONE' || !input + return input === 'DEFAULT' || input === 'QUICK' || input === 'NONE' || !input } function isLibraryPattern(input: string): input is LibraryPattern { @@ -89,7 +89,7 @@ const buildScema = (existingLibraries: Library[], library?: Library) => message: 'Invalid library, parent directory already exists as library.', }), ), - scan_mode: z.string().refine(isLibraryScanMode).default('BATCHED'), + scan_mode: z.string().refine(isLibraryScanMode).default('DEFAULT'), tags: z .array( z.object({ @@ -138,7 +138,7 @@ export default function CreateOrEditLibraryForm({ library, existingLibraries }: library_pattern: library?.library_options.library_pattern || 'SERIES_BASED', name: library?.name, path: library?.path, - scan_mode: 'BATCHED', + scan_mode: 'DEFAULT', tags: library?.tags?.map((t) => ({ label: t.name, value: t.name })), // @ts-expect-error: mostly null vs undefined issues thumbnail_config: library?.library_options.thumbnail_config @@ -317,6 +317,7 @@ export default function CreateOrEditLibraryForm({ library, existingLibraries }: containerClassName="max-w-full md:max-w-sm" required errorMessage={errors.name?.message} + data-1p-ignore {...form.register('name')} />
diff --git a/packages/interface/src/scenes/settings/job/JobTable.tsx b/packages/interface/src/scenes/settings/job/JobTable.tsx index c98838f15..ab66c3530 100644 --- a/packages/interface/src/scenes/settings/job/JobTable.tsx +++ b/packages/interface/src/scenes/settings/job/JobTable.tsx @@ -132,6 +132,7 @@ export default function JobTable() { columnHelper.display({ cell: ({ row }) => (isServerOwner ? : null), id: 'actions', + size: 28, }), ], [t, isServerOwner], diff --git a/packages/interface/src/scenes/settings/user/login-activity/LoginActivityTable.tsx b/packages/interface/src/scenes/settings/user/login-activity/LoginActivityTable.tsx index 829fa5445..4180acf4e 100644 --- a/packages/interface/src/scenes/settings/user/login-activity/LoginActivityTable.tsx +++ b/packages/interface/src/scenes/settings/user/login-activity/LoginActivityTable.tsx @@ -65,7 +65,7 @@ const baseColumns = [ columnHelper.display({ cell: ({ row: { original: activity } }) => ( - {activity.authentication_successful ? 'Successful' : 'Failure'} + {activity.authentication_successful ? 'Success' : 'Failure'} ), header: 'Auth result', diff --git a/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx b/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx index 50aa34106..69680ce75 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserActionMenu.tsx @@ -49,7 +49,7 @@ export default function UserActionMenu({ user }: Props) { onClick: () => setDeletingUser(user), }, { - disabled: isSelf, + disabled: isSelf || user.role === 'SERVER_OWNER', label: `${user.is_locked ? 'Unlock' : 'Lock'} account`, leftIcon: user.is_locked ? ( diff --git a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx index c94d674a6..4ad4e6494 100644 --- a/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx +++ b/packages/interface/src/scenes/settings/user/user-table/UserTable.tsx @@ -1,7 +1,8 @@ -import { Badge, Text } from '@stump/components' +import { Badge, Text, ToolTip } from '@stump/components' import { User } from '@stump/types' import { createColumnHelper, getCoreRowModel } from '@tanstack/react-table' import dayjs from 'dayjs' +import { HelpCircle } from 'lucide-react' import Table from '../../../../components/table/Table' import { useUserManagementContext } from '../context' @@ -59,7 +60,14 @@ const baseColumns = [ {original.login_sessions_count} ), - header: 'Active sessions', + header: () => ( +
+ Active sessions + + + +
+ ), id: 'login_sessions_count', }), columnHelper.display({ diff --git a/packages/types/core.ts b/packages/types/core.ts index 2e56b3be2..99a4cb187 100644 --- a/packages/types/core.ts +++ b/packages/types/core.ts @@ -19,7 +19,7 @@ export type Library = { id: string; name: string; description: string | null; pa export type LibraryPattern = "SERIES_BASED" | "COLLECTION_BASED" -export type LibraryScanMode = "SYNC" | "BATCHED" | "NONE" +export type LibraryScanMode = "DEFAULT" | "QUICK" | "NONE" export type LibraryOptions = { id: string | null; convert_rar_to_zip: boolean; hard_delete_conversions: boolean; library_pattern: LibraryPattern; thumbnail_config: ImageProcessorOptions | null; library_id: string | null }