simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 8140a43dac4c21f11f0849a6ad593bc717078889
parent e337c44c1a8928d8072b34cfd4c0b9e4299e6afe
Author: Silas Brack <silasbrack@gmail.com>
Date:   Fri, 12 Jun 2026 20:22:04 +0200

feat: replace TrailBase with direct SQLite access

Removes the TrailBase HTTP API dependency and queries SQLite
directly via rusqlite with r2d2 connection pooling. This
eliminates the ~5x overhead of HTTP serialization between the
app and database.

Changes:
- New src/database.rs: r2d2 SQLite pool with all queries,
  schema init (tables, indices, FTS5, triggers)
- New src/auth.rs: local auth with argon2id password hashing
  and JWT tokens (replaces TrailBase auth)
- SSE comment notifications via tokio::sync::broadcast channel
  (replaces TrailBase SSE subscription)
- Removed: trailbase.rs, trailbase_types.rs, trailbase-client
  dep, reqwest dep, /api/* proxy route
- Updated all handlers to use spawn_blocking + direct SQL
- Updated NixOS module: removed TrailBase service
- Updated flake: removed openssl dependency

No template changes required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
M.gitignore | 1+
MCargo.lock | 1756+++++++++++++++----------------------------------------------------------------
MCargo.toml | 11++++++-----
Mflake.nix | 21+++------------------
Asrc/auth.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/config.rs | 17+++++++++++++----
Asrc/database.rs | 1004+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/db/mod.rs | 4++--
Msrc/error.rs | 12+++++-------
Msrc/handlers/auth.rs | 146++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/handlers/comment.rs | 106+++++++++++++++++++++++++++----------------------------------------------------
Msrc/handlers/feed.rs | 97+++++++++++++++++++++++++++----------------------------------------------------
Msrc/handlers/search.rs | 32+++++++++++---------------------
Msrc/handlers/story.rs | 442+++++++++++++++++++++++++++++++++----------------------------------------------
Msrc/handlers/tag.rs | 129+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/handlers/user.rs | 133++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/handlers/vote.rs | 57++++++++++++++-------------------------------------------
Msrc/main.rs | 10++++++----
Msrc/routes.rs | 37-------------------------------------
Msrc/state.rs | 29++++++++++++++++++++++++-----
Msrc/templates.rs | 2+-
Dsrc/trailbase.rs | 912-------------------------------------------------------------------------------
Dsrc/trailbase_types.rs | 64----------------------------------------------------------------
23 files changed, 1913 insertions(+), 3170 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -24,3 +24,4 @@ tags TAGS .idea/ *.iml +data/ diff --git a/Cargo.lock b/Cargo.lock @@ -3,6 +3,18 @@ version = 4 [[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -12,6 +24,24 @@ dependencies = [ ] [[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] name = "askama" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -56,17 +86,6 @@ dependencies = [ ] [[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -79,28 +98,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -176,12 +173,6 @@ dependencies = [ ] [[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -209,6 +200,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -236,28 +236,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] [[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -274,31 +271,6 @@ dependencies = [ ] [[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -310,26 +282,6 @@ dependencies = [ ] [[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -345,15 +297,12 @@ dependencies = [ ] [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "libc", ] [[package]] @@ -367,44 +316,6 @@ dependencies = [ ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -420,134 +331,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] [[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "ff" -version = "0.13.1" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "find-msvc-tools" @@ -556,10 +360,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "fnv" -version = "1.0.7" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" @@ -571,12 +375,6 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -592,36 +390,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "futures-sink" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -640,11 +408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -657,7 +421,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -675,46 +438,34 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", - "wasm-bindgen", + "wasip3", ] [[package]] -name = "group" -version = "0.13.0" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", + "ahash", ] [[package]] -name = "h2" -version = "0.4.13" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "foldhash", ] [[package]] @@ -724,22 +475,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] -name = "hkdf" -version = "0.12.4" +name = "hashlink" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hmac", + "hashbrown 0.14.5", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" @@ -805,7 +553,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -814,22 +561,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", ] [[package]] @@ -838,23 +569,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", "tokio", "tower-service", - "tracing", - "windows-registry", ] [[package]] @@ -882,235 +603,59 @@ dependencies = [ ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.95" +name = "js-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "10.3.0" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", "js-sys", - "p256", - "p384", - "rand 0.8.6", - "rsa", + "pem", + "ring", "serde", "serde_json", - "sha2", - "signature", + "simple_asn1", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -1125,10 +670,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "litemap" -version = "0.8.2" +name = "libsqlite3-sys" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "lock_api" @@ -1146,12 +696,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1207,19 +751,13 @@ dependencies = [ ] [[package]] -name = "num-bigint-dig" -version = "0.8.6" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "lazy_static", - "libm", "num-integer", - "num-iter", "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", ] [[package]] @@ -1238,24 +776,12 @@ dependencies = [ ] [[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1265,42 +791,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1324,12 +814,24 @@ dependencies = [ ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "password-hash" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", ] [[package]] @@ -1345,34 +847,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "powerfmt" @@ -1390,12 +868,13 @@ dependencies = [ ] [[package]] -name = "primeorder" -version = "0.13.6" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "elliptic-curve", + "proc-macro2", + "syn", ] [[package]] @@ -1408,95 +887,62 @@ dependencies = [ ] [[package]] -name = "quinn" -version = "0.11.9" +name = "quote" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", + "proc-macro2", ] [[package]] -name = "quinn-proto" -version = "0.11.14" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "r2d2" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", + "log", + "parking_lot", + "scheduled-thread-pool", ] [[package]] -name = "quote" -version = "1.0.45" +name = "r2d2_sqlite" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2" dependencies = [ - "proc-macro2", + "r2d2", + "rusqlite", + "uuid", ] [[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] name = "rand" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1510,16 +956,6 @@ dependencies = [ ] [[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1530,12 +966,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" @@ -1547,59 +980,6 @@ dependencies = [ ] [[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1614,113 +994,17 @@ dependencies = [ ] [[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustls" -version = "0.23.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "rusqlite" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -1736,21 +1020,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "same-file" -version = "1.0.6" +name = "scheduled-thread-pool" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", + "parking_lot", ] [[package]] @@ -1760,43 +1035,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1857,17 +1095,6 @@ dependencies = [ ] [[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1880,51 +1107,43 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] name = "simple-web-app" version = "0.1.0" dependencies = [ + "argon2", "askama", - "async-trait", "axum", "axum-extra", "chrono", - "http", - "reqwest", + "jsonwebtoken", + "r2d2", + "r2d2_sqlite", + "rand 0.8.6", + "rusqlite", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", - "trailbase-client", - "url", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", ] [[package]] @@ -1950,28 +1169,6 @@ dependencies = [ ] [[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1993,69 +1190,14 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2101,37 +1243,11 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] name = "tokio" version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ - "bytes", "libc", "mio", "pin-project-lite", @@ -2152,16 +1268,6 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] name = "tokio-stream" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2203,24 +1309,6 @@ dependencies = [ ] [[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2265,35 +1353,6 @@ dependencies = [ ] [[package]] -name = "trailbase-client" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c21a25a9beb5d157503b25da848b74513195632673f341d4c8577c2b15e19f3b" -dependencies = [ - "async-trait", - "bytes", - "eventsource-stream", - "futures-lite", - "http", - "http-body-util", - "jsonwebtoken", - "parking_lot", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "thiserror 2.0.18", - "tracing", - "url", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2312,28 +1371,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "url" -version = "2.5.8" +name = "uuid" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", + "getrandom 0.4.2", + "js-sys", + "rand 0.10.1", + "wasm-bindgen", ] [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" @@ -2342,25 +1407,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2372,7 +1418,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2389,16 +1444,6 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "wasm-bindgen-macro" version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2431,54 +1476,37 @@ dependencies = [ ] [[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "js-sys", - "wasm-bindgen", + "leb128fmt", + "wasmparser", ] [[package]] -name = "webpki-root-certs" -version = "1.0.7" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "rustls-pki-types", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "winapi-util" -version = "0.1.11" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "windows-sys 0.61.2", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -2523,17 +1551,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2553,29 +1570,11 @@ dependencies = [ [[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.2", -] - -[[package]] -name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2589,189 +1588,76 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] name = "wit-bindgen" @@ -2780,108 +1666,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "yoke-derive" -version = "0.8.2" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "proc-macro2", - "quote", + "anyhow", + "heck", + "indexmap", + "prettyplease", "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "zerocopy-derive" -version = "0.8.48" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", "syn", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "zeroize" -version = "1.8.2" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "displaydoc", - "yoke", - "zerofrom", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "zerovec" -version = "0.11.6" +name = "zerocopy" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", + "zerocopy-derive", ] [[package]] -name = "zerovec-derive" -version = "0.11.3" +name = "zerocopy-derive" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } askama = "0.12" -async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } tokio-stream = { version = "0.1", features = ["sync"] } @@ -15,7 +14,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tracing = "0.1" -http = "1" -reqwest = { version = "0.13", features = ["json", "stream"] } -trailbase-client = "0.8" -url = "2" +rusqlite = { version = "0.31", features = ["bundled"] } +r2d2 = "0.8" +r2d2_sqlite = "0.24" +argon2 = "0.5" +jsonwebtoken = "9" +rand = "0.8" diff --git a/flake.nix b/flake.nix @@ -44,16 +44,10 @@ version = "0.1.0"; strictDeps = true; - buildInputs = with pkgs; [ - openssl - ] ++ lib.optionals pkgs.stdenv.isDarwin [ + buildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.darwin.apple_sdk.frameworks.Security pkgs.darwin.apple_sdk.frameworks.SystemConfiguration ]; - - nativeBuildInputs = with pkgs; [ - pkg-config - ]; }; # Build just the cargo dependencies @@ -66,11 +60,10 @@ inherit cargoArtifacts; } ); - trailbase = pkgs.callPackage ./trailbase.nix { }; in { default = simple-web-app; - inherit simple-web-app trailbase; + inherit simple-web-app; } ); @@ -102,16 +95,10 @@ version = "0.1.0"; strictDeps = true; - buildInputs = with pkgs; [ - openssl - ] ++ lib.optionals pkgs.stdenv.isDarwin [ + buildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.darwin.apple_sdk.frameworks.Security pkgs.darwin.apple_sdk.frameworks.SystemConfiguration ]; - - nativeBuildInputs = with pkgs; [ - pkg-config - ]; }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; @@ -168,8 +155,6 @@ rust-analyzer quicktype - # Backend - (pkgs.callPackage ./trailbase.nix { }) ] ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.darwin.apple_sdk.frameworks.Security pkgs.darwin.apple_sdk.frameworks.SystemConfiguration diff --git a/src/auth.rs b/src/auth.rs @@ -0,0 +1,61 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: String, // user_id as string + exp: usize, +} + +pub fn hash_password(password: &str) -> Result<String, AppError> { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::Database(format!("Password hash error: {}", e)))?; + Ok(hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> { + let parsed_hash = PasswordHash::new(hash) + .map_err(|e| AppError::Database(format!("Invalid hash format: {}", e)))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +pub fn create_token(user_id: i64, secret: &str) -> Result<String, AppError> { + let expiration = chrono::Utc::now() + .checked_add_signed(chrono::Duration::hours(24)) + .ok_or_else(|| AppError::Database("Failed to calculate token expiry".to_string()))? + .timestamp() as usize; + + let claims = Claims { + sub: user_id.to_string(), + exp: expiration, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .map_err(|e| AppError::Database(format!("Token creation error: {}", e))) +} + +pub fn verify_token(token: &str, secret: &str) -> Option<i64> { + let token_data = decode::<Claims>( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + ) + .ok()?; + + token_data.claims.sub.parse().ok() +} diff --git a/src/config.rs b/src/config.rs @@ -1,15 +1,23 @@ use std::env; pub struct Config { - pub trailbase_url: String, + pub db_path: String, + pub jwt_secret: String, pub debug: bool, pub port: u16, } impl Config { pub fn from_env() -> Self { - let trailbase_url = - env::var("TRAILBASE_URL").unwrap_or_else(|_| "http://localhost:4000".to_string()); + let db_path = env::var("DB_PATH").unwrap_or_else(|_| "data/app.db".to_string()); + + let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| { + // Generate a random secret if not provided + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 32] = rng.gen(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() + }); let debug = env::var("DEBUG") .map(|v| v.to_lowercase() == "true" || v == "1") @@ -21,7 +29,8 @@ impl Config { .unwrap_or(8000); Self { - trailbase_url, + db_path, + jwt_secret, debug, port, } diff --git a/src/database.rs b/src/database.rs @@ -0,0 +1,1004 @@ +use std::collections::HashMap; + +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::params; + +use crate::error::AppError; + +// Target type constants for votes +pub const TARGET_TYPE_STORY: i64 = 1; +pub const TARGET_TYPE_COMMENT: i64 = 2; + +// ========================================================================== +// Data types +// ========================================================================== + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Story { + pub id: i64, + pub url: Option<String>, + pub title: String, + pub text: String, + pub domain: Option<String>, + pub score: i64, + pub comment_count: i64, + pub published: String, + pub created_by: Option<String>, + pub author: Option<String>, + pub language: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Comment { + pub id: i64, + pub story_id: i64, + pub parent_id: Option<i64>, + pub path: String, + pub depth: i64, + pub text: String, + pub score: i64, + pub created_at: String, + pub created_by: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Category { + pub id: i64, + pub name: Option<String>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct User { + pub id: i64, + pub email: String, + pub password_hash: String, + pub username: String, + pub about: Option<String>, + pub karma: i64, + pub created_at: String, +} + +/// Kept for template compatibility - maps to User fields +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UserProfile { + pub id: i64, + pub user_id: String, + pub username: String, + pub about: Option<String>, + pub karma: i64, + pub created_at: String, +} + +impl From<User> for UserProfile { + fn from(u: User) -> Self { + Self { + id: u.id, + user_id: u.id.to_string(), + username: u.username, + about: u.about, + karma: u.karma, + created_at: u.created_at, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SearchResult { + pub id: i64, + pub title: String, + pub text: String, + pub score: i64, + pub comment_count: i64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StoryMeta { + pub story_id: i64, + pub author_username: Option<String>, + pub tags: Vec<String>, +} + +// ========================================================================== +// Database +// ========================================================================== + +pub struct Database { + pool: Pool<SqliteConnectionManager>, +} + +impl Database { + pub fn new(path: &str) -> Result<Self, AppError> { + // Ensure parent directory exists + if let Some(parent) = std::path::Path::new(path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| AppError::Database(format!("Failed to create db directory: {}", e)))?; + } + + let manager = SqliteConnectionManager::file(path); + let pool = Pool::builder() + .max_size(10) + .build(manager) + .map_err(|e| AppError::Database(format!("Failed to create pool: {}", e)))?; + + let db = Self { pool }; + db.init_schema()?; + Ok(db) + } + + fn get_conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>, AppError> { + self.pool + .get() + .map_err(|e| AppError::Database(format!("Failed to get connection: {}", e))) + } + + fn init_schema(&self) -> Result<(), AppError> { + let conn = self.get_conn()?; + + conn.execute_batch(" + PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + PRAGMA busy_timeout=5000; + PRAGMA cache_size=-64000; + ").map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {}", e)))?; + + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + about TEXT, + karma INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS story ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT, + title TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + published TEXT NOT NULL DEFAULT (datetime('now')), + author TEXT, + language TEXT NOT NULL DEFAULT 'en', + created_by INTEGER REFERENCES user(id), + score INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + domain TEXT + ); + + CREATE TABLE IF NOT EXISTS comment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + story_id INTEGER NOT NULL REFERENCES story(id), + parent_id INTEGER REFERENCES comment(id), + path TEXT NOT NULL DEFAULT '', + depth INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL DEFAULT '', + score INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER NOT NULL REFERENCES user(id) + ); + + CREATE TABLE IF NOT EXISTS vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES user(id), + target_type INTEGER NOT NULL, + target_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, target_type, target_id) + ); + + CREATE TABLE IF NOT EXISTS category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT + ); + + CREATE TABLE IF NOT EXISTS story_category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + news_item_id INTEGER NOT NULL REFERENCES story(id), + category_id INTEGER NOT NULL REFERENCES category(id) + ); + + -- FTS index + CREATE VIRTUAL TABLE IF NOT EXISTS story_fts USING fts5( + title, text, + content='story', + content_rowid='id', + tokenize='porter unicode61' + ); + + -- Indices + CREATE INDEX IF NOT EXISTS idx_story_published ON story(published DESC); + CREATE INDEX IF NOT EXISTS idx_story_score ON story(score DESC); + CREATE INDEX IF NOT EXISTS idx_story_created_by ON story(created_by); + CREATE INDEX IF NOT EXISTS idx_comment_story_id ON comment(story_id); + CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id); + CREATE INDEX IF NOT EXISTS idx_comment_created_by ON comment(created_by); + CREATE INDEX IF NOT EXISTS idx_vote_user_target ON vote(user_id, target_type, target_id); + CREATE INDEX IF NOT EXISTS idx_story_category_news_item ON story_category(news_item_id); + CREATE INDEX IF NOT EXISTS idx_story_category_category ON story_category(category_id); + ").map_err(|e| AppError::Database(format!("Failed to create tables: {}", e)))?; + + // Create triggers (use IF NOT EXISTS via checking sqlite_master) + let trigger_exists = |name: &str| -> Result<bool, AppError> { + let exists: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='trigger' AND name=?1", + params![name], + |row| row.get(0), + ).map_err(|e| AppError::Database(format!("Trigger check failed: {}", e)))?; + Ok(exists) + }; + + if !trigger_exists("story_fts_insert")? { + conn.execute_batch(" + CREATE TRIGGER story_fts_insert AFTER INSERT ON story BEGIN + INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); + END; + ").map_err(|e| AppError::Database(format!("FTS insert trigger: {}", e)))?; + } + + if !trigger_exists("story_fts_update")? { + conn.execute_batch(" + CREATE TRIGGER story_fts_update AFTER UPDATE ON story BEGIN + INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); + INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); + END; + ").map_err(|e| AppError::Database(format!("FTS update trigger: {}", e)))?; + } + + if !trigger_exists("story_fts_delete")? { + conn.execute_batch(" + CREATE TRIGGER story_fts_delete AFTER DELETE ON story BEGIN + INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); + END; + ").map_err(|e| AppError::Database(format!("FTS delete trigger: {}", e)))?; + } + + if !trigger_exists("comment_count_insert")? { + conn.execute_batch(" + CREATE TRIGGER comment_count_insert AFTER INSERT ON comment BEGIN + UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id; + END; + ").map_err(|e| AppError::Database(format!("comment_count insert trigger: {}", e)))?; + } + + if !trigger_exists("comment_count_delete")? { + conn.execute_batch(" + CREATE TRIGGER comment_count_delete AFTER DELETE ON comment BEGIN + UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id; + END; + ").map_err(|e| AppError::Database(format!("comment_count delete trigger: {}", e)))?; + } + + if !trigger_exists("vote_insert_story")? { + conn.execute_batch(" + CREATE TRIGGER vote_insert_story AFTER INSERT ON vote WHEN new.target_type = 1 BEGIN + UPDATE story SET score = score + 1 WHERE id = new.target_id; + UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM story WHERE id = new.target_id); + END; + ").map_err(|e| AppError::Database(format!("vote insert story trigger: {}", e)))?; + } + + if !trigger_exists("vote_delete_story")? { + conn.execute_batch(" + CREATE TRIGGER vote_delete_story AFTER DELETE ON vote WHEN old.target_type = 1 BEGIN + UPDATE story SET score = score - 1 WHERE id = old.target_id; + UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM story WHERE id = old.target_id); + END; + ").map_err(|e| AppError::Database(format!("vote delete story trigger: {}", e)))?; + } + + if !trigger_exists("vote_insert_comment")? { + conn.execute_batch(" + CREATE TRIGGER vote_insert_comment AFTER INSERT ON vote WHEN new.target_type = 2 BEGIN + UPDATE comment SET score = score + 1 WHERE id = new.target_id; + UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM comment WHERE id = new.target_id); + END; + ").map_err(|e| AppError::Database(format!("vote insert comment trigger: {}", e)))?; + } + + if !trigger_exists("vote_delete_comment")? { + conn.execute_batch(" + CREATE TRIGGER vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 BEGIN + UPDATE comment SET score = score - 1 WHERE id = old.target_id; + UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id); + END; + ").map_err(|e| AppError::Database(format!("vote delete comment trigger: {}", e)))?; + } + + Ok(()) + } + + // ====================================================================== + // Story queries + // ====================================================================== + + fn row_to_story(row: &rusqlite::Row) -> rusqlite::Result<Story> { + let created_by: Option<i64> = row.get("created_by")?; + Ok(Story { + id: row.get("id")?, + url: row.get("url")?, + title: row.get("title")?, + text: row.get("text")?, + domain: row.get("domain")?, + score: row.get("score")?, + comment_count: row.get("comment_count")?, + published: row.get("published")?, + created_by: created_by.map(|id| id.to_string()), + author: row.get("author")?, + language: row.get("language")?, + }) + } + + pub fn get_stories_new(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM story ORDER BY published DESC LIMIT ?1 OFFSET ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(stories) + } + + pub fn get_stories_top(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM story ORDER BY score DESC, published DESC LIMIT ?1 OFFSET ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(stories) + } + + pub fn get_stories_hot(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { + let fetch_limit = (limit + offset) * 2; + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM story ORDER BY published DESC LIMIT ?1" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let mut stories: Vec<Story> = stmt.query_map(params![fetch_limit as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + stories.sort_by(|a, b| { + let score_a = hot_score(a.score, &a.published); + let score_b = hot_score(b.score, &b.published); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect(); + Ok(result) + } + + pub fn get_story(&self, id: i64) -> Result<Option<Story>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT * FROM story WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt.query_map(params![id], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))?; + + match rows.next() { + Some(Ok(story)) => Ok(Some(story)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + } + + pub fn create_story( + &self, + user_id: i64, + title: &str, + url: Option<&str>, + text: &str, + domain: Option<&str>, + language: &str, + ) -> Result<Story, AppError> { + let conn = self.get_conn()?; + let published = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + conn.execute( + "INSERT INTO story (title, url, text, domain, published, language, created_by) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![title, url, text, domain, published, language, user_id], + ).map_err(|e| AppError::Database(e.to_string()))?; + + let id = conn.last_insert_rowid(); + self.get_story(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created story".to_string())) + } + + pub fn update_story( + &self, + id: i64, + title: &str, + url: Option<&str>, + text: &str, + domain: Option<&str>, + ) -> Result<(), AppError> { + let conn = self.get_conn()?; + conn.execute( + "UPDATE story SET title = ?1, url = ?2, text = ?3, domain = ?4 WHERE id = ?5", + params![title, url, text, domain, id], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn get_stories_by_tag(&self, category_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT s.* FROM story s + JOIN story_category sc ON sc.news_item_id = s.id + WHERE sc.category_id = ?1 + ORDER BY s.published DESC + LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let stories = stmt.query_map(params![category_id, limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(stories) + } + + pub fn get_stories_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM story WHERE created_by = ?1 ORDER BY published DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let stories = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(stories) + } + + pub fn search_stories(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT s.id, s.title, s.text, s.score, s.comment_count + FROM story_fts fts + JOIN story s ON s.id = fts.rowid + WHERE story_fts MATCH ?1 + ORDER BY rank + LIMIT ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let results = stmt.query_map(params![query, limit as i64], |row| { + Ok(SearchResult { + id: row.get(0)?, + title: row.get(1)?, + text: row.get(2)?, + score: row.get(3)?, + comment_count: row.get(4)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(results) + } + + // ====================================================================== + // Comment queries + // ====================================================================== + + fn row_to_comment(row: &rusqlite::Row) -> rusqlite::Result<Comment> { + let created_by: i64 = row.get("created_by")?; + Ok(Comment { + id: row.get("id")?, + story_id: row.get("story_id")?, + parent_id: row.get("parent_id")?, + path: row.get("path")?, + depth: row.get("depth")?, + text: row.get("text")?, + score: row.get("score")?, + created_at: row.get("created_at")?, + created_by: created_by.to_string(), + }) + } + + pub fn get_comments_for_story(&self, story_id: i64) -> Result<Vec<Comment>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM comment WHERE story_id = ?1 ORDER BY path" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let comments = stmt.query_map(params![story_id], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(comments) + } + + pub fn create_comment( + &self, + user_id: i64, + story_id: i64, + parent_id: Option<i64>, + text: &str, + ) -> Result<Comment, AppError> { + let conn = self.get_conn()?; + + // Calculate path and depth + let (path, depth) = match parent_id { + Some(pid) => { + let parent: Option<(String, i64)> = conn.query_row( + "SELECT path, depth FROM comment WHERE id = ?1", + params![pid], + |row| Ok((row.get(0)?, row.get(1)?)), + ).ok(); + + match parent { + Some((parent_path, parent_depth)) => { + let sibling_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM comment WHERE parent_id = ?1", + params![pid], + |row| row.get(0), + ).map_err(|e| AppError::Database(e.to_string()))?; + + let path = format!("{}.{}", parent_path, sibling_count + 1); + (path, parent_depth + 1) + } + None => { + // Parent not found, treat as root + let root_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM comment WHERE story_id = ?1 AND depth = 0", + params![story_id], + |row| row.get(0), + ).map_err(|e| AppError::Database(e.to_string()))?; + ((root_count + 1).to_string(), 0) + } + } + } + None => { + let root_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM comment WHERE story_id = ?1 AND depth = 0", + params![story_id], + |row| row.get(0), + ).map_err(|e| AppError::Database(e.to_string()))?; + ((root_count + 1).to_string(), 0) + } + }; + + conn.execute( + "INSERT INTO comment (story_id, parent_id, path, depth, text, created_by) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![story_id, parent_id, path, depth, text, user_id], + ).map_err(|e| AppError::Database(e.to_string()))?; + + let id = conn.last_insert_rowid(); + self.get_comment(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created comment".to_string())) + } + + pub fn get_comment(&self, id: i64) -> Result<Option<Comment>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT * FROM comment WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt.query_map(params![id], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))?; + + match rows.next() { + Some(Ok(comment)) => Ok(Some(comment)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + } + + pub fn get_comments_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Comment>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT * FROM comment WHERE created_by = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let comments = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(comments) + } + + // ====================================================================== + // Vote queries + // ====================================================================== + + /// Toggle vote: INSERT if not exists, DELETE if exists. Returns true if now voted. + pub fn toggle_vote(&self, user_id: i64, target_type: i64, target_id: i64) -> Result<bool, AppError> { + let conn = self.get_conn()?; + + // Try to insert + let result = conn.execute( + "INSERT INTO vote (user_id, target_type, target_id) VALUES (?1, ?2, ?3)", + params![user_id, target_type, target_id], + ); + + match result { + Ok(_) => Ok(true), + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ffi::ErrorCode::ConstraintViolation => + { + // Already voted, remove it + conn.execute( + "DELETE FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id = ?3", + params![user_id, target_type, target_id], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(false) + } + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + pub fn get_user_votes(&self, user_id: i64, target_type: i64, target_ids: &[i64]) -> Result<Vec<i64>, AppError> { + if target_ids.is_empty() { + return Ok(vec![]); + } + + let conn = self.get_conn()?; + + // Build placeholders for IN clause + let placeholders: Vec<String> = target_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect(); + let sql = format!( + "SELECT target_id FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare(&sql).map_err(|e| AppError::Database(e.to_string()))?; + + // Build params + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + param_values.push(Box::new(user_id)); + param_values.push(Box::new(target_type)); + for id in target_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let voted_ids = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0)) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(voted_ids) + } + + // ====================================================================== + // Category queries + // ====================================================================== + + pub fn get_categories(&self, limit: usize) -> Result<Vec<Category>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT id, name FROM category LIMIT ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let categories = stmt.query_map(params![limit as i64], |row| { + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(categories) + } + + pub fn get_categories_for_story(&self, story_id: i64) -> Result<Vec<Category>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare( + "SELECT c.id, c.name FROM category c + JOIN story_category sc ON sc.category_id = c.id + WHERE sc.news_item_id = ?1" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let categories = stmt.query_map(params![story_id], |row| { + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(categories) + } + + pub fn add_tag_to_story(&self, story_id: i64, category_id: i64) -> Result<(), AppError> { + let conn = self.get_conn()?; + conn.execute( + "INSERT INTO story_category (news_item_id, category_id) VALUES (?1, ?2)", + params![story_id, category_id], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn remove_all_tags_from_story(&self, story_id: i64) -> Result<(), AppError> { + let conn = self.get_conn()?; + conn.execute( + "DELETE FROM story_category WHERE news_item_id = ?1", + params![story_id], + ).map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + // ====================================================================== + // User queries + // ====================================================================== + + fn row_to_user(row: &rusqlite::Row) -> rusqlite::Result<User> { + Ok(User { + id: row.get("id")?, + email: row.get("email")?, + password_hash: row.get("password_hash")?, + username: row.get("username")?, + about: row.get("about")?, + karma: row.get("karma")?, + created_at: row.get("created_at")?, + }) + } + + pub fn get_user_by_id(&self, id: i64) -> Result<Option<User>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT * FROM user WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt.query_map(params![id], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; + + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + } + + pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT * FROM user WHERE email = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt.query_map(params![email], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; + + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + } + + pub fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> { + let conn = self.get_conn()?; + let mut stmt = conn.prepare("SELECT * FROM user WHERE username = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut rows = stmt.query_map(params![username], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; + + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + } + + pub fn create_user(&self, email: &str, password_hash: &str, username: &str) -> Result<User, AppError> { + let conn = self.get_conn()?; + conn.execute( + "INSERT INTO user (email, password_hash, username) VALUES (?1, ?2, ?3)", + params![email, password_hash, username], + ).map_err(|e| AppError::Database(e.to_string()))?; + + let id = conn.last_insert_rowid(); + self.get_user_by_id(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created user".to_string())) + } + + // ====================================================================== + // Enrichment queries + // ====================================================================== + + pub fn enrich_stories(&self, story_ids: &[i64]) -> Result<Vec<StoryMeta>, AppError> { + if story_ids.is_empty() { + return Ok(vec![]); + } + + let conn = self.get_conn()?; + + // Fetch author usernames + let placeholders: Vec<String> = story_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let sql = format!( + "SELECT s.id, u.username FROM story s + LEFT JOIN user u ON u.id = s.created_by + WHERE s.id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare(&sql).map_err(|e| AppError::Database(e.to_string()))?; + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + for id in story_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let author_map: HashMap<i64, Option<String>> = stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + // Fetch tags + let tag_sql = format!( + "SELECT sc.news_item_id, c.name FROM story_category sc + JOIN category c ON c.id = sc.category_id + WHERE sc.news_item_id IN ({})", + placeholders.join(", ") + ); + + let mut tag_stmt = conn.prepare(&tag_sql).map_err(|e| AppError::Database(e.to_string()))?; + + let mut tags_map: HashMap<i64, Vec<String>> = HashMap::new(); + let tag_rows = tag_stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + for row in tag_rows { + if let Ok((story_id, Some(name))) = row { + tags_map.entry(story_id).or_default().push(name); + } + } + + let result: Vec<StoryMeta> = story_ids + .iter() + .map(|&sid| StoryMeta { + story_id: sid, + author_username: author_map.get(&sid).cloned().flatten(), + tags: tags_map.get(&sid).cloned().unwrap_or_default(), + }) + .collect(); + + Ok(result) + } + + pub fn enrich_comment_authors(&self, user_ids: &[i64]) -> Result<HashMap<i64, String>, AppError> { + if user_ids.is_empty() { + return Ok(HashMap::new()); + } + + let conn = self.get_conn()?; + let placeholders: Vec<String> = user_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let sql = format!( + "SELECT id, username FROM user WHERE id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare(&sql).map_err(|e| AppError::Database(e.to_string()))?; + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + for id in user_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let map: HashMap<i64, String> = stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(map) + } + + /// Get the score of a story + pub fn get_story_score(&self, id: i64) -> Result<i64, AppError> { + let conn = self.get_conn()?; + conn.query_row("SELECT score FROM story WHERE id = ?1", params![id], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string())) + } + + /// Get the score of a comment + pub fn get_comment_score(&self, id: i64) -> Result<i64, AppError> { + let conn = self.get_conn()?; + conn.query_row("SELECT score FROM comment WHERE id = ?1", params![id], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string())) + } +} + +// ========================================================================== +// Helper functions +// ========================================================================== + +/// Calculate hot score for ranking (HN-style algorithm) +pub fn hot_score(points: i64, published: &str) -> f64 { + let age_hours = hours_since(published); + (points as f64) / (age_hours + 2.0).powf(1.8) +} + +/// Calculate hours since a timestamp +fn hours_since(timestamp: &str) -> f64 { + use chrono::{DateTime, Utc}; + + let published = timestamp + .parse::<DateTime<Utc>>() + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.and_utc()) + }) + .unwrap_or_else(|_| Utc::now()); + + let now = Utc::now(); + let duration = now.signed_duration_since(published); + duration.num_minutes() as f64 / 60.0 +} + +/// Format a timestamp as "X ago" string +pub fn time_ago(timestamp: &str) -> String { + use chrono::{DateTime, Utc}; + + let published = timestamp + .parse::<DateTime<Utc>>() + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.and_utc()) + }) + .unwrap_or_else(|_| Utc::now()); + + let now = Utc::now(); + let duration = now.signed_duration_since(published); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + + if days > 0 { + if days == 1 { + "1 day ago".to_string() + } else { + format!("{} days ago", days) + } + } else if hours > 0 { + if hours == 1 { + "1 hour ago".to_string() + } else { + format!("{} hours ago", hours) + } + } else if minutes > 0 { + if minutes == 1 { + "1 minute ago".to_string() + } else { + format!("{} minutes ago", minutes) + } + } else { + "just now".to_string() + } +} + +/// Extract domain from URL +pub fn extract_domain(url_str: &str) -> Option<String> { + url_str + .split("//") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .map(|host| host.trim_start_matches("www.").to_string()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs @@ -1,5 +1,5 @@ -// Re-export types from trailbase client -pub use crate::trailbase::{Category, Story, Comment, UserProfile}; +// Re-export types from database module +pub use crate::database::{Category, Comment, Story, UserProfile}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct StoryWithMeta { diff --git a/src/error.rs b/src/error.rs @@ -2,12 +2,10 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use thiserror::Error; -use crate::trailbase::ClientError; - #[derive(Error, Debug)] pub enum AppError { - #[error("API error: {0}")] - Api(#[from] ClientError), + #[error("Database error: {0}")] + Database(String), #[error("Template error: {0}")] Template(#[from] askama::Error), @@ -22,9 +20,9 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { - AppError::Api(e) => { - eprintln!("API error: {}", e); - (StatusCode::BAD_GATEWAY, format!("API error: {}", e)) + AppError::Database(e) => { + eprintln!("Database error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)) } AppError::Template(e) => { eprintln!("Template error: {}", e); diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs @@ -7,21 +7,21 @@ use axum::{ use axum_extra::extract::cookie::{Cookie, CookieJar}; use serde::Deserialize; +use crate::auth; use crate::error::AppError; use crate::state::AppState; use crate::templates::{ApplicationTemplate, LoginTemplate, RegisterTemplate, ResendVerificationTemplate}; -use crate::trailbase::{self, StoredTokens}; -const AUTH_COOKIE: &str = "auth_tokens"; +const AUTH_COOKIE: &str = "auth_token"; -pub fn get_tokens_from_cookies(jar: &CookieJar) -> Option<StoredTokens> { +/// Extract user_id from the auth cookie JWT +pub fn get_user_id_from_cookies(jar: &CookieJar, jwt_secret: &str) -> Option<i64> { jar.get(AUTH_COOKIE) - .and_then(|c| serde_json::from_str(c.value()).ok()) + .and_then(|c| auth::verify_token(c.value(), jwt_secret)) } -fn set_auth_cookie(jar: CookieJar, tokens: &StoredTokens) -> CookieJar { - let value = serde_json::to_string(tokens).unwrap_or_default(); - let cookie = Cookie::build((AUTH_COOKIE, value)) +fn set_auth_cookie(jar: CookieJar, token: &str) -> CookieJar { + let cookie = Cookie::build((AUTH_COOKIE, token.to_string())) .path("/") .http_only(true) .secure(true) @@ -64,33 +64,29 @@ pub async fn login( jar: CookieJar, Form(form): Form<LoginForm>, ) -> Result<(CookieJar, Redirect), Html<String>> { - let client = trailbase::new_client(&state.http_client, &state.trailbase_url).map_err(|e| { - render_login_error(&format!("Client error: {}", e)) - })?; - - match trailbase::login(&client, &form.email, &form.password).await { - Ok(tokens) => { - // Ensure user has a profile, create one if missing - if let Ok(auth_client) = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) { - if let Some(user_id) = trailbase::extract_user_id_from_token(&tokens.auth_token) { - match trailbase::get_user_profile_by_id(&auth_client, &user_id).await { - Ok(Some(_)) => {}, - Ok(None) => { - let username = form.email.split('@').next().unwrap_or("user"); - if let Err(e) = trailbase::create_user_profile(&auth_client, username).await { - tracing::error!("Failed to create user profile for {}: {}", user_id, e); - } - } - Err(e) => { - tracing::error!("Failed to check user profile for {}: {}", user_id, e); - } - } - } - } - let jar = set_auth_cookie(jar, &tokens); + let email = form.email.clone(); + let password = form.password.clone(); + let jwt_secret = state.jwt_secret.clone(); + + let result = state.db_query(move |db| { + let user = db.get_user_by_email(&email)? + .ok_or_else(|| AppError::Unauthorized("Invalid email or password".to_string()))?; + + let valid = auth::verify_password(&password, &user.password_hash)?; + if !valid { + return Err(AppError::Unauthorized("Invalid email or password".to_string())); + } + + let token = auth::create_token(user.id, &jwt_secret)?; + Ok(token) + }).await; + + match result { + Ok(token) => { + let jar = set_auth_cookie(jar, &token); Ok((jar, Redirect::to("/"))) } - Err(e) => Err(render_login_error(&format!("Login failed: {}", e))), + Err(e) => Err(render_login_error(&format!("{}", e))), } } @@ -119,7 +115,6 @@ pub async fn register( )); } - // Only allow alphanumeric and underscores if !form .username .chars() @@ -135,44 +130,41 @@ pub async fn register( return Err(render_register_error(&form, "Passwords do not match")); } - let client = trailbase::new_client(&state.http_client, &state.trailbase_url).map_err(|e| { - render_register_error(&form, &format!("Client error: {}", e)) - })?; - - // Check if username is already taken - if let Ok(Some(_)) = trailbase::get_user_profile_by_username(&client, &form.username).await { - return Err(render_register_error(&form, "Username is already taken")); - } + let username = form.username.clone(); + let email = form.email.clone(); + let password = form.password.clone(); + let jwt_secret = state.jwt_secret.clone(); - // Register the user - match trailbase::register(&client, &state.http_client, &form.email, &form.password).await { - Ok(tokens) => { - // Create authenticated client and user profile - if let Ok(auth_client) = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) { - if let Err(e) = trailbase::create_user_profile(&auth_client, &form.username).await { - tracing::warn!("Failed to create user profile: {}", e); - } - } - - let jar = set_auth_cookie(jar, &tokens); - Ok((jar, Redirect::to("/"))) + let result = state.db_query(move |db| { + // Check if username is taken + if db.get_user_by_username(&username)?.is_some() { + return Err(AppError::Database("Username is already taken".to_string())); } - Err(trailbase::ClientError::EmailVerificationRequired) => { - Err(render_register_success("Registration successful! Please check your email to verify your account.")) + + // Check if email is taken + if db.get_user_by_email(&email)?.is_some() { + return Err(AppError::Database("Email is already registered".to_string())); } - Err(e) => { - Err(render_register_error( - &form, - &format!("Registration failed: {}", e), - )) + + let password_hash = auth::hash_password(&password)?; + let user = db.create_user(&email, &password_hash, &username)?; + let token = auth::create_token(user.id, &jwt_secret)?; + Ok(token) + }).await; + + match result { + Ok(token) => { + let jar = set_auth_cookie(jar, &token); + Ok((jar, Redirect::to("/"))) } + Err(e) => Err(render_register_error(&form, &format!("{}", e))), } } pub async fn show_resend_verification() -> Result<Html<String>, AppError> { let template = ResendVerificationTemplate { error: None, - message: None, + message: Some("Email verification is not required with the current setup.".to_string()), }; let app = ApplicationTemplate { content: template.render()?, @@ -180,33 +172,19 @@ pub async fn show_resend_verification() -> Result<Html<String>, AppError> { Ok(Html(app.render()?)) } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct ResendVerificationForm { + #[allow(dead_code)] pub email: String, } pub async fn resend_verification( - State(state): State<AppState>, - Form(form): Form<ResendVerificationForm>, + Form(_form): Form<ResendVerificationForm>, ) -> Result<Html<String>, AppError> { - let base = state.trailbase_url.trim_end_matches('/'); - let url = format!("{}/api/auth/v1/verify_email/trigger?email={}", base, url::form_urlencoded::byte_serialize(form.email.as_bytes()).collect::<String>()); - let response = state.http_client.get(&url).send().await; - - let (message, error) = match response { - Ok(resp) if resp.status().is_success() => { - (Some("Verification email sent! Check your inbox.".to_string()), None) - } - Ok(resp) => { - let body = resp.text().await.unwrap_or_default(); - (None, Some(format!("Failed to send: {}", body))) - } - Err(e) => { - (None, Some(format!("Request failed: {}", e))) - } + let template = ResendVerificationTemplate { + error: None, + message: Some("Email verification is not required with the current setup.".to_string()), }; - - let template = ResendVerificationTemplate { error, message }; let app = ApplicationTemplate { content: template.render()?, }; @@ -228,12 +206,6 @@ fn render_login_error(error: &str) -> Html<String> { Html(app.render().unwrap_or_default()) } -fn render_register_success(message: &str) -> Html<String> { - let content = format!("<div class=\"auth-page\"><h2>Check Your Email</h2><p>{}</p><p class=\"auth-link\"><a href=\"/login\">Go to Login</a></p></div>", message); - let app = ApplicationTemplate { content }; - Html(app.render().unwrap_or_default()) -} - fn render_register_error(form: &RegisterForm, error: &str) -> Html<String> { let register = RegisterTemplate { error: Some(error.to_string()), diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs @@ -8,10 +8,9 @@ use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, ReplyFormTemplate}; -use crate::trailbase; #[derive(Debug, Deserialize)] pub struct CommentForm { @@ -25,33 +24,23 @@ pub async fn create_comment( Path(story_id): Path<i64>, Form(form): Form<CommentForm>, ) -> Result<Redirect, Html<String>> { - let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) - .map_err(|e| Html(format!("Client error: {}", e)))?; - if form.text.trim().is_empty() { return Err(Html("Comment text is required".to_string())); } - // Calculate path for root comment - let (path, depth) = trailbase::calculate_comment_path(&client, story_id, None) - .await - .map_err(|e| Html(format!("Failed to calculate path: {}", e)))?; - - let create_comment = trailbase::CreateComment { - story_id, - parent_id: None, - path, - depth, - text: form.text.trim().to_string(), - }; + let text = form.text.trim().to_string(); + let comment_tx = state.comment_tx.clone(); + + state.db_query(move |db| { + db.create_comment(user_id, story_id, None, &text) + }).await.map_err(|e| Html(format!("Failed to create comment: {}", e)))?; - trailbase::create_comment(&client, create_comment) - .await - .map_err(|e| Html(format!("Failed to create comment: {}", e)))?; + // Notify SSE subscribers + let _ = comment_tx.send(story_id); Ok(Redirect::to(&format!("/story/{}", story_id))) } @@ -62,43 +51,29 @@ pub async fn show_reply_form( jar: CookieJar, Path(comment_id): Path<i64>, ) -> Result<Html<String>, AppError> { - let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); - if !is_logged_in { + if user_id.is_none() { return Ok(Html( r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), )); } - let tokens = get_tokens_from_cookies(&jar) - .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens)?; - - // Get the parent comment to show what we're replying to - let args = trailbase_client::ListArguments::new() - .with_filters(trailbase_client::Filter::new( - "id", - trailbase_client::CompareOp::Equal, - comment_id.to_string(), - )); - - let response = client - .records("comment") - .list::<trailbase::Comment>(args) - .await - .map_err(trailbase::ClientError::from)?; + let (parent_comment, parent_author) = state.db_query(move |db| { + let comment = db.get_comment(comment_id)? + .ok_or_else(|| AppError::NotFound("Comment not found".to_string()))?; - let parent_comment = response - .records - .into_iter() - .next() - .ok_or_else(|| AppError::NotFound("Comment not found".to_string()))?; + let author = if let Ok(uid) = comment.created_by.parse::<i64>() { + db.get_user_by_id(uid) + .ok() + .flatten() + .map(|u| u.username) + } else { + None + }; - let parent_author = trailbase::get_user_profile_by_id(&client, &parent_comment.created_by) - .await - .ok() - .flatten() - .map(|p| p.username); + Ok((comment, author)) + }).await?; let reply_form = ReplyFormTemplate { comment_id, @@ -127,33 +102,24 @@ pub async fn submit_reply( Path(parent_id): Path<i64>, Form(form): Form<ReplyForm>, ) -> Result<Redirect, Html<String>> { - let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) - .map_err(|e| Html(format!("Client error: {}", e)))?; - if form.text.trim().is_empty() { return Err(Html("Reply text is required".to_string())); } - // Calculate path for reply - let (path, depth) = trailbase::calculate_comment_path(&client, form.story_id, Some(parent_id)) - .await - .map_err(|e| Html(format!("Failed to calculate path: {}", e)))?; - - let create_comment = trailbase::CreateComment { - story_id: form.story_id, - parent_id: Some(parent_id), - path, - depth, - text: form.text.trim().to_string(), - }; + let text = form.text.trim().to_string(); + let story_id = form.story_id; + let comment_tx = state.comment_tx.clone(); - trailbase::create_comment(&client, create_comment) - .await - .map_err(|e| Html(format!("Failed to create reply: {}", e)))?; + state.db_query(move |db| { + db.create_comment(user_id, story_id, Some(parent_id), &text) + }).await.map_err(|e| Html(format!("Failed to create reply: {}", e)))?; - Ok(Redirect::to(&format!("/story/{}", form.story_id))) + // Notify SSE subscribers + let _ = comment_tx.send(story_id); + + Ok(Redirect::to(&format!("/story/{}", story_id))) } diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs @@ -7,11 +7,11 @@ use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use crate::db::{Category, StoryWithMeta}; +use crate::database::{self, TARGET_TYPE_STORY}; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, FeedTemplate}; -use crate::trailbase::{self, TARGET_TYPE_STORY}; const PAGE_LIMIT: usize = 30; @@ -41,70 +41,46 @@ async fn fetch_feed( query: &FeedQuery, sort: FeedSort, ) -> Result<(Vec<StoryWithMeta>, Vec<Category>, bool), AppError> { - let request_start = std::time::Instant::now(); let offset = query.page * PAGE_LIMIT; + let user_id = get_user_id_from_cookies(jar, &state.jwt_secret); + let tag_id = query.tag; + let page = query.page; - // Create client (with auth if available) - let tokens = get_tokens_from_cookies(jar); - let client = match &tokens { - Some(t) => trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, t)?, - None => trailbase::new_client(&state.http_client, &state.trailbase_url)?, - }; + let (stories, categories) = state.db_query(move |db| { + let categories = db.get_categories(20)?; - // Fetch categories/tags - let t = std::time::Instant::now(); - let categories = trailbase::get_categories(&client, 20).await?; - let categories_ms = t.elapsed().as_millis(); - - // Fetch stories based on sort and optional tag filter - let t = std::time::Instant::now(); - let stories = match query.tag { - Some(tag_id) => { - trailbase::get_stories_by_tag(&state.http_client, &state.trailbase_url, &client, tag_id, PAGE_LIMIT + 1, offset).await? - } - None => match sort { - FeedSort::Hot => { - trailbase::get_stories_hot(&client, PAGE_LIMIT + 1, offset).await? - } - FeedSort::New => { - trailbase::get_stories_new(&client, PAGE_LIMIT + 1, offset).await? - } - FeedSort::Top => { - trailbase::get_stories_top(&client, PAGE_LIMIT + 1, offset).await? - } - }, - }; + let stories = match tag_id { + Some(tid) => db.get_stories_by_tag(tid, PAGE_LIMIT + 1, offset)?, + None => match sort { + FeedSort::Hot => db.get_stories_hot(PAGE_LIMIT + 1, offset)?, + FeedSort::New => db.get_stories_new(PAGE_LIMIT + 1, offset)?, + FeedSort::Top => db.get_stories_top(PAGE_LIMIT + 1, offset)?, + }, + }; - let stories_ms = t.elapsed().as_millis(); + Ok((stories, categories)) + }).await?; let reached_end = stories.len() <= PAGE_LIMIT; let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); - // Get story IDs for vote checking let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); - // Check which stories the user has voted on - let t = std::time::Instant::now(); - let voted_ids = if tokens.is_some() { - trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) - .await - .inspect_err(|e| tracing::error!("Failed to get user votes: {}", e)) - .unwrap_or_default() - } else { - vec![] - }; - let votes_ms = t.elapsed().as_millis(); + let (voted_ids, meta) = state.db_query(move |db| { + let voted_ids = if let Some(uid) = user_id { + db.get_user_votes(uid, TARGET_TYPE_STORY, &story_ids) + .unwrap_or_default() + } else { + vec![] + }; - // Batch fetch tags and authors in 2 SQL queries instead of N+1 - let t = std::time::Instant::now(); - let meta = trailbase::enrich_stories(&state.http_client, &state.trailbase_url, &story_ids) - .await - .inspect_err(|e| tracing::error!("Failed to enrich stories: {}", e)) - .unwrap_or_default(); - let enrich_ms = t.elapsed().as_millis(); + let meta = db.enrich_stories(&story_ids).unwrap_or_default(); + + Ok((voted_ids, meta)) + }).await?; let mut enriched_stories = Vec::with_capacity(stories.len()); - let base_rank = query.page * PAGE_LIMIT; + let base_rank = page * PAGE_LIMIT; for (idx, story) in stories.into_iter().enumerate() { let story_meta = meta.iter().find(|m| m.story_id == story.id); @@ -113,7 +89,7 @@ async fn fetch_feed( .unwrap_or_default(); let author_username = story_meta.and_then(|m| m.author_username.clone()); - let time_ago = trailbase::time_ago(&story.published); + let time_ago = database::time_ago(&story.published); let user_voted = voted_ids.contains(&story.id); let rank = base_rank + idx + 1; @@ -127,13 +103,6 @@ async fn fetch_feed( )); } - tracing::info!( - "fetch_feed sort={:?} total={}ms categories={}ms stories={}ms votes={}ms enrich={}ms", - sort, - request_start.elapsed().as_millis(), - categories_ms, stories_ms, votes_ms, enrich_ms - ); - Ok((enriched_stories, categories, reached_end)) } @@ -143,7 +112,7 @@ pub async fn show_feed( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Hot).await?; - let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); let feed = FeedTemplate { stories, @@ -167,7 +136,7 @@ pub async fn show_new( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::New).await?; - let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); let feed = FeedTemplate { stories, @@ -191,7 +160,7 @@ pub async fn show_top( Query(query): Query<FeedQuery>, ) -> Result<Html<String>, AppError> { let (stories, tags, reached_end) = fetch_feed(&state, &jar, &query, FeedSort::Top).await?; - let is_logged_in = get_tokens_from_cookies(&jar).is_some(); + let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); let feed = FeedTemplate { stories, diff --git a/src/handlers/search.rs b/src/handlers/search.rs @@ -1,21 +1,23 @@ use askama::Template; use axum::{ - extract::State, response::{Html, Response}, Form, + extract::State, }; use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::sse; use crate::state::AppState; use crate::templates::{ApplicationTemplate, SearchResultsTemplate, SearchTemplate}; -use crate::trailbase::{ClientError, SearchResult}; -pub async fn open_search(jar: CookieJar) -> Result<Html<String>, AppError> { - let is_logged_in = get_tokens_from_cookies(&jar).is_some(); +pub async fn open_search( + State(state): State<AppState>, + jar: CookieJar, +) -> Result<Html<String>, AppError> { + let is_logged_in = get_user_id_from_cookies(&jar, &state.jwt_secret).is_some(); let template = SearchTemplate { is_logged_in }; let app = ApplicationTemplate { content: template.render()?, @@ -36,22 +38,10 @@ pub async fn search( let results = if form.search.is_empty() { Vec::new() } else { - let base = state.trailbase_url.trim_end_matches('/'); - let url = format!( - "{}/search?q={}&limit=20", - base, - url::form_urlencoded::byte_serialize(form.search.as_bytes()).collect::<String>() - ); - - state - .http_client - .get(&url) - .send() - .await - .map_err(|e| AppError::Api(ClientError::Auth(format!("Search request failed: {}", e))))? - .json::<Vec<SearchResult>>() - .await - .map_err(|e| AppError::Api(ClientError::Auth(format!("Search parse failed: {}", e))))? + let query = form.search.clone(); + state.db_query(move |db| { + db.search_stories(&query, 20) + }).await? }; let template = SearchResultsTemplate { results }; diff --git a/src/handlers/story.rs b/src/handlers/story.rs @@ -8,67 +8,62 @@ use axum::{ }; use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; -use tokio_stream::StreamExt; use crate::db::{CommentWithMeta, StoryWithMeta}; +use crate::database::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, CommentsTemplate, EditTemplate, StoryTemplate, SubmitTemplate}; -use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; async fn fetch_enriched_comments( state: &AppState, - tokens: &Option<trailbase::StoredTokens>, - client: &trailbase_client::Client, + user_id: Option<i64>, story_id: i64, ) -> Result<Vec<CommentWithMeta>, AppError> { - let comments = trailbase::get_comments_for_story(client, story_id).await?; - - let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); - let voted_comment_ids = if tokens.is_some() && !comment_ids.is_empty() { - trailbase::get_user_votes(client, TARGET_TYPE_COMMENT, &comment_ids) - .await - .inspect_err(|e| tracing::error!("Failed to get comment votes: {}", e)) - .unwrap_or_default() - } else { - vec![] - }; + state.db_query(move |db| { + let comments = db.get_comments_for_story(story_id)?; + + let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect(); + let voted_comment_ids = if let Some(uid) = user_id { + if !comment_ids.is_empty() { + db.get_user_votes(uid, TARGET_TYPE_COMMENT, &comment_ids) + .unwrap_or_default() + } else { + vec![] + } + } else { + vec![] + }; - // Batch-fetch comment author usernames (1 query instead of N) - let unique_user_ids: Vec<&str> = { - let mut ids: Vec<&str> = comments.iter().map(|c| c.created_by.as_str()).collect(); - ids.sort_unstable(); - ids.dedup(); - ids - }; - let author_map = trailbase::enrich_comment_authors( - &state.http_client, - &state.trailbase_url, - &unique_user_ids, - ) - .await - .inspect_err(|e| tracing::error!("Failed to enrich comment authors: {}", e)) - .unwrap_or_default(); - - let mut enriched = Vec::with_capacity(comments.len()); - for comment in comments { - let comment_author = author_map - .iter() - .find(|m| m.user_id == comment.created_by) - .map(|m| m.username.clone()); - let comment_time_ago = trailbase::time_ago(&comment.created_at); - let comment_voted = voted_comment_ids.contains(&comment.id); - - enriched.push(CommentWithMeta::from_comment( - comment, - comment_author, - comment_time_ago, - comment_voted, - )); - } + // Batch-fetch comment author usernames + let unique_user_ids: Vec<i64> = { + let mut ids: Vec<i64> = comments.iter() + .filter_map(|c| c.created_by.parse::<i64>().ok()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + let author_map = db.enrich_comment_authors(&unique_user_ids).unwrap_or_default(); + + let mut enriched = Vec::with_capacity(comments.len()); + for comment in comments { + let uid: i64 = comment.created_by.parse().unwrap_or(0); + let comment_author = author_map.get(&uid).cloned(); + let comment_time_ago = database::time_ago(&comment.created_at); + let comment_voted = voted_comment_ids.contains(&comment.id); + + enriched.push(CommentWithMeta::from_comment( + comment, + comment_author, + comment_time_ago, + comment_voted, + )); + } - Ok(enriched) + Ok(enriched) + }).await } pub async fn show_story( @@ -76,67 +71,46 @@ pub async fn show_story( jar: CookieJar, Path(id): Path<i64>, ) -> Result<Html<String>, AppError> { - let request_start = std::time::Instant::now(); - let tokens = get_tokens_from_cookies(&jar); - let client = match &tokens { - Some(t) => trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, t)?, - None => trailbase::new_client(&state.http_client, &state.trailbase_url)?, - }; + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + + let (story, tags, author_username, story_voted) = state.db_query(move |db| { + let story = db.get_story(id)? + .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; + + let tags = db.get_categories_for_story(id).unwrap_or_default(); + + let author_username = if let Some(ref created_by) = story.created_by { + if let Ok(uid) = created_by.parse::<i64>() { + db.get_user_by_id(uid) + .ok() + .flatten() + .map(|u| u.username) + } else { + None + } + } else { + None + }; - // Fetch the story - let t = std::time::Instant::now(); - let story = trailbase::get_story(&client, id) - .await? - .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; - let get_story_ms = t.elapsed().as_millis(); - - // Get tags for the story - let t = std::time::Instant::now(); - let tags = trailbase::get_categories_for_story(&client, id) - .await - .inspect_err(|e| tracing::error!("Failed to get tags for story {}: {}", id, e)) - .unwrap_or_default(); - let get_tags_ms = t.elapsed().as_millis(); - - let t = std::time::Instant::now(); - let author_username = if let Some(ref created_by) = story.created_by { - trailbase::get_user_profile_by_id(&client, created_by) - .await - .inspect_err(|e| tracing::error!("Failed to get profile for {}: {}", created_by, e)) - .ok() - .flatten() - .map(|p| p.username) - } else { - None - }; - let get_author_ms = t.elapsed().as_millis(); - - let t = std::time::Instant::now(); - let story_voted = if tokens.is_some() { - trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &[id]) - .await - .inspect_err(|e| tracing::error!("Failed to get votes: {}", e)) - .map(|v| v.contains(&id)) - .unwrap_or(false) - } else { - false - }; - let get_votes_ms = t.elapsed().as_millis(); + let story_voted = if let Some(uid) = user_id { + db.get_user_votes(uid, TARGET_TYPE_STORY, &[id]) + .map(|v| v.contains(&id)) + .unwrap_or(false) + } else { + false + }; - // Enrich story - let time_ago = trailbase::time_ago(&story.published); + Ok((story, tags, author_username, story_voted)) + }).await?; + + let time_ago = database::time_ago(&story.published); let story_with_meta = StoryWithMeta::from_story(story, tags, author_username, time_ago, story_voted, 0); - let t = std::time::Instant::now(); - let enriched_comments = fetch_enriched_comments(&state, &tokens, &client, id).await?; - let get_comments_ms = t.elapsed().as_millis(); + let enriched_comments = fetch_enriched_comments(&state, user_id, id).await?; - let is_logged_in = tokens.is_some(); - let current_user_id = tokens - .as_ref() - .and_then(|t| trailbase::extract_user_id_from_token(&t.auth_token)); + let is_logged_in = user_id.is_some(); + let current_user_id = user_id.map(|id| id.to_string()); - let t = std::time::Instant::now(); let story_template = StoryTemplate { story: story_with_meta, comments: enriched_comments, @@ -147,32 +121,21 @@ pub async fn show_story( let app = ApplicationTemplate { content: story_template.render()?, }; - let html = app.render()?; - let render_ms = t.elapsed().as_millis(); - - tracing::info!( - "show_story id={} total={}ms story={}ms tags={}ms author={}ms votes={}ms comments={}ms render={}ms", - id, - request_start.elapsed().as_millis(), - get_story_ms, get_tags_ms, get_author_ms, get_votes_ms, get_comments_ms, render_ms - ); - - Ok(Html(html)) + Ok(Html(app.render()?)) } pub async fn show_submit( State(state): State<AppState>, jar: CookieJar, ) -> Result<Html<String>, AppError> { - let tokens = get_tokens_from_cookies(&jar); - if tokens.is_none() { + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + if user_id.is_none() { return Ok(Html( r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(), )); } - let client = trailbase::new_client(&state.http_client, &state.trailbase_url)?; - let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + let all_tags = state.db_query(|db| db.get_categories(50)).await.unwrap_or_default(); let submit = SubmitTemplate { error: None, @@ -207,14 +170,11 @@ pub async fn submit_story( jar: CookieJar, Form(form): Form<SubmitForm>, ) -> Result<Redirect, Html<String>> { - let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + let uid = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) - .map_err(|e| render_submit_error(&form, &[], &format!("Client error: {}", e)))?; - - let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + let all_tags = state.db_query(|db| db.get_categories(50)).await.unwrap_or_default(); // Validate: must have either URL or text let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); @@ -234,39 +194,47 @@ pub async fn submit_story( // Validate URL if provided if let Some(u) = url { - if url::Url::parse(u).is_err() { + // Simple URL validation + if !u.starts_with("http://") && !u.starts_with("https://") { return Err(render_submit_error(&form, &all_tags, "Invalid URL. Must start with http:// or https://")); } } - // Extract domain from URL - let domain = url.and_then(|u| trailbase::extract_domain(u)); - - // Create the story - let create_story = trailbase::CreateStory { - url: url.map(|s| s.to_string()), - title: form.title.trim().to_string(), - text: text.map(|s| s.to_string()).unwrap_or_default(), - domain, - published: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), - language: "en".to_string(), - }; - - let story = trailbase::create_story(&client, create_story) - .await - .map_err(|e| render_submit_error(&form, &all_tags, &format!("Failed to create story: {}", e)))?; + let domain = url.and_then(|u| database::extract_domain(u)); + let title = form.title.trim().to_string(); + let url_val = url.map(|s| s.to_string()); + let text_val = text.map(|s| s.to_string()).unwrap_or_default(); + let tags_str = form.tags.clone(); + + let story = state.db_query(move |db| { + db.create_story( + uid, + &title, + url_val.as_deref(), + &text_val, + domain.as_deref(), + "en", + ) + }).await.map_err(|e| render_submit_error(&form, &all_tags, &format!("Failed to create story: {}", e)))?; // Add tags - for tag_id in parse_tags(&form.tags) { - if let Err(e) = trailbase::add_tag_to_story(&client, story.id, tag_id).await { - tracing::warn!("Failed to add tag {} to story {}: {}", tag_id, story.id, e); - } + let story_id = story.id; + let tag_ids = parse_tags(&tags_str); + if !tag_ids.is_empty() { + let _ = state.db_query(move |db| { + for tag_id in tag_ids { + if let Err(e) = db.add_tag_to_story(story_id, tag_id) { + tracing::warn!("Failed to add tag {} to story {}: {}", tag_id, story_id, e); + } + } + Ok(()) + }).await; } Ok(Redirect::to(&format!("/story/{}", story.id))) } -fn render_submit_error(form: &SubmitForm, all_tags: &[trailbase::Category], error: &str) -> Html<String> { +fn render_submit_error(form: &SubmitForm, all_tags: &[database::Category], error: &str) -> Html<String> { let submit = SubmitTemplate { error: Some(error.to_string()), title: form.title.clone(), @@ -290,24 +258,27 @@ pub async fn show_edit( jar: CookieJar, Path(id): Path<i64>, ) -> Result<Html<String>, AppError> { - let tokens = get_tokens_from_cookies(&jar) + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens)?; + let (story, all_tags, story_tags) = state.db_query(move |db| { + let story = db.get_story(id)? + .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; - let story = trailbase::get_story(&client, id) - .await? - .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?; + // Check ownership + let story_owner: i64 = story.created_by.as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if story_owner != user_id { + return Err(AppError::Unauthorized("You can only edit your own stories".to_string())); + } - // Check ownership - let user_id = trailbase::extract_user_id_from_token(&tokens.auth_token) - .ok_or_else(|| AppError::Unauthorized("Invalid token".to_string()))?; - if story.created_by.as_deref() != Some(&user_id) { - return Err(AppError::Unauthorized("You can only edit your own stories".to_string())); - } + let all_tags = db.get_categories(50).unwrap_or_default(); + let story_tags = db.get_categories_for_story(id).unwrap_or_default(); + + Ok((story, all_tags, story_tags)) + }).await?; - let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); - let story_tags = trailbase::get_categories_for_story(&client, id).await.unwrap_or_default(); let selected_tags: Vec<i64> = story_tags.iter().map(|t| t.id).collect(); let edit = EditTemplate { @@ -341,55 +312,49 @@ pub async fn edit_story( Path(id): Path<i64>, Form(form): Form<EditForm>, ) -> Result<Redirect, Html<String>> { - let tokens = get_tokens_from_cookies(&jar).ok_or_else(|| { + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret).ok_or_else(|| { Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string()) })?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens) - .map_err(|e| render_edit_error(id, &form, &[], &format!("Client error: {}", e)))?; - // Check ownership - let user_id = trailbase::extract_user_id_from_token(&tokens.auth_token) - .ok_or_else(|| render_edit_error(id, &form, &[], "Invalid token"))?; - - let story = trailbase::get_story(&client, id) - .await - .map_err(|e| render_edit_error(id, &form, &[], &format!("{}", e)))? - .ok_or_else(|| render_edit_error(id, &form, &[], "Story not found"))?; - - if story.created_by.as_deref() != Some(&user_id) { + let story = state.db_query(move |db| { + db.get_story(id)?.ok_or_else(|| AppError::NotFound("Story not found".to_string())) + }).await.map_err(|e| render_edit_error(id, &form, &[], &format!("{}", e)))?; + + let story_owner: i64 = story.created_by.as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if story_owner != user_id { return Err(render_edit_error(id, &form, &[], "You can only edit your own stories")); } if form.title.trim().is_empty() { - let all_tags = trailbase::get_categories(&client, 50).await.unwrap_or_default(); + let all_tags = state.db_query(|db| db.get_categories(50)).await.unwrap_or_default(); return Err(render_edit_error(id, &form, &all_tags, "Title is required")); } let url = form.url.as_ref().filter(|u| !u.trim().is_empty()); let text = form.text.as_ref().filter(|t| !t.trim().is_empty()); - let domain = url.and_then(|u| trailbase::extract_domain(u)); + let domain = url.and_then(|u| database::extract_domain(u)); - let update = trailbase::UpdateStory { - url: url.map(|s| s.to_string()), - title: form.title.trim().to_string(), - text: text.map(|s| s.to_string()).unwrap_or_default(), - domain, - }; + let title = form.title.trim().to_string(); + let url_val = url.map(|s| s.to_string()); + let text_val = text.map(|s| s.to_string()).unwrap_or_default(); + let tags_str = form.tags.clone(); - trailbase::update_story(&client, id, update) - .await - .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?; + state.db_query(move |db| { + db.update_story(id, &title, url_val.as_deref(), &text_val, domain.as_deref())?; - // Update tags: remove all, then re-add selected - if let Err(e) = trailbase::remove_all_tags_from_story(&client, id).await { - tracing::warn!("Failed to remove tags from story {}: {}", id, e); - } - for tag_id in parse_tags(&form.tags) { - if let Err(e) = trailbase::add_tag_to_story(&client, id, tag_id).await { - tracing::warn!("Failed to add tag {} to story {}: {}", tag_id, id, e); + // Update tags: remove all, then re-add selected + db.remove_all_tags_from_story(id)?; + for tag_id in parse_tags(&tags_str) { + if let Err(e) = db.add_tag_to_story(id, tag_id) { + tracing::warn!("Failed to add tag {} to story {}: {}", tag_id, id, e); + } } - } + + Ok(()) + }).await.map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?; Ok(Redirect::to(&format!("/story/{}", id))) } @@ -422,93 +387,54 @@ fn render_comments_event( ) } -/// Long-lived SSE endpoint that bridges TrailBase comment subscriptions -/// to Datastar patch events. Sends the current comments immediately on -/// connect, then pushes updates whenever TrailBase notifies of changes. +/// Long-lived SSE endpoint that uses broadcast channel for comment notifications. pub async fn comments_stream( State(state): State<AppState>, jar: CookieJar, Path(story_id): Path<i64>, ) -> Response { - let tokens = get_tokens_from_cookies(&jar); + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let is_logged_in = user_id.is_some(); let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(10); + let mut broadcast_rx = state.comment_tx.subscribe(); + let state_clone = state.clone(); tokio::spawn(async move { - // Helper to build a TrailBase client - let make_client = || -> Option<trailbase_client::Client> { - match &tokens { - Some(t) => trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, t).ok(), - None => trailbase::new_client(&state.http_client, &state.trailbase_url).ok(), - } - }; - - let is_logged_in = tokens.is_some(); - - // Send current comments immediately so we can verify the endpoint works - if let Some(client) = make_client() { - if let Ok(comments) = - fetch_enriched_comments(&state, &tokens, &client, story_id).await - { + // Send current comments immediately + match fetch_enriched_comments(&state_clone, user_id, story_id).await { + Ok(comments) => { if let Some(event) = render_comments_event(comments, is_logged_in, story_id) { if tx.send(Ok(event)).await.is_err() { return; } } } - } - - // Subscribe to TrailBase SSE for change notifications - let subscribe_url = format!( - "{}/api/records/v1/comment/subscribe/*?filter[story_id]={}", - state.trailbase_url.trim_end_matches('/'), - story_id - ); - - let response = match state.http_client.get(&subscribe_url).send().await { - Ok(r) => r, Err(e) => { - tracing::error!( - "Failed to subscribe to comments for story {}: {}", - story_id, - e - ); + tracing::error!("Failed to fetch initial comments for SSE: {}", e); return; } - }; - - let mut byte_stream = response.bytes_stream(); - let mut buffer = Vec::new(); - - while let Some(chunk) = byte_stream.next().await { - let chunk = match chunk { - Ok(c) => c, - Err(_) => break, - }; - buffer.extend_from_slice(&chunk); + } - // Detect SSE event boundary (double newline) - if !buffer.windows(2).any(|w| w == b"\n\n") { - continue; - } - buffer.clear(); - - // Fetch fresh comments and push to client - let client = match make_client() { - Some(c) => c, - None => continue, - }; - - match fetch_enriched_comments(&state, &tokens, &client, story_id).await { - Ok(comments) => { - if let Some(event) = render_comments_event(comments, is_logged_in, story_id) { - if tx.send(Ok(event)).await.is_err() { - break; + // Wait for broadcast notifications + loop { + match broadcast_rx.recv().await { + Ok(changed_story_id) if changed_story_id == story_id => { + match fetch_enriched_comments(&state_clone, user_id, story_id).await { + Ok(comments) => { + if let Some(event) = render_comments_event(comments, is_logged_in, story_id) { + if tx.send(Ok(event)).await.is_err() { + break; + } + } + } + Err(e) => { + tracing::error!("Failed to fetch comments for SSE: {}", e); } } } - Err(e) => { - tracing::error!("Failed to fetch comments for SSE: {}", e); - } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, } } }); @@ -525,7 +451,7 @@ pub async fn comments_stream( response } -fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[trailbase::Category], error: &str) -> Html<String> { +fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[database::Category], error: &str) -> Html<String> { let edit = EditTemplate { error: Some(error.to_string()), story_id, diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs @@ -7,11 +7,11 @@ use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use crate::db::{Category, StoryWithMeta}; +use crate::database::{self, TARGET_TYPE_STORY}; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, TagTemplate}; -use crate::trailbase::{self, TARGET_TYPE_STORY}; const PAGE_LIMIT: usize = 30; @@ -27,72 +27,71 @@ pub async fn show_tag( Path(tag_name): Path<String>, Query(query): Query<TagQuery>, ) -> Result<Html<String>, AppError> { - let tokens = get_tokens_from_cookies(&jar); - let client = match &tokens { - Some(t) => trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, t)?, - None => trailbase::new_client(&state.http_client, &state.trailbase_url)?, - }; - - // Find the category by name - let categories = trailbase::get_categories(&client, 100).await?; - let category = categories - .iter() - .find(|c| c.name.as_deref() == Some(&tag_name)) - .ok_or_else(|| AppError::NotFound(format!("Tag '{}' not found", tag_name)))?; - + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); let offset = query.page * PAGE_LIMIT; - let stories = trailbase::get_stories_by_tag(&state.http_client, &state.trailbase_url, &client, category.id, PAGE_LIMIT + 1, offset).await?; - - let reached_end = stories.len() <= PAGE_LIMIT; - let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); - - // Get story IDs for vote checking and enrichment - let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); - - let voted_ids = if tokens.is_some() && !story_ids.is_empty() { - trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) - .await - .inspect_err(|e| tracing::error!("Failed to get votes: {}", e)) - .unwrap_or_default() - } else { - vec![] - }; - - // Batch fetch tags and authors - let meta = trailbase::enrich_stories(&state.http_client, &state.trailbase_url, &story_ids) - .await - .inspect_err(|e| tracing::error!("Failed to enrich stories: {}", e)) - .unwrap_or_default(); - - let mut enriched_stories = Vec::with_capacity(stories.len()); - let base_rank = query.page * PAGE_LIMIT; - - for (idx, story) in stories.into_iter().enumerate() { - let story_meta = meta.iter().find(|m| m.story_id == story.id); - let tags: Vec<Category> = story_meta - .map(|m| m.tags.iter().map(|name| Category { id: 0, name: Some(name.clone()) }).collect()) - .unwrap_or_default(); - let author_username = story_meta.and_then(|m| m.author_username.clone()); - - let time_ago = trailbase::time_ago(&story.published); - let user_voted = voted_ids.contains(&story.id); - let rank = base_rank + idx + 1; - - enriched_stories.push(StoryWithMeta::from_story( - story, - tags, - author_username, - time_ago, - user_voted, - rank, - )); - } - - let is_logged_in = tokens.is_some(); + let page = query.page; + let tag_name_clone = tag_name.clone(); + + let (stories, reached_end) = state.db_query(move |db| { + // Find the category by name + let categories = db.get_categories(100)?; + let category = categories + .iter() + .find(|c| c.name.as_deref() == Some(&tag_name_clone)) + .ok_or_else(|| AppError::NotFound(format!("Tag '{}' not found", tag_name_clone)))?; + + let stories = db.get_stories_by_tag(category.id, PAGE_LIMIT + 1, offset)?; + + let reached_end = stories.len() <= PAGE_LIMIT; + let stories: Vec<_> = stories.into_iter().take(PAGE_LIMIT).collect(); + + let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); + + let voted_ids = if let Some(uid) = user_id { + if !story_ids.is_empty() { + db.get_user_votes(uid, TARGET_TYPE_STORY, &story_ids) + .unwrap_or_default() + } else { + vec![] + } + } else { + vec![] + }; + + let meta = db.enrich_stories(&story_ids).unwrap_or_default(); + + let base_rank = page * PAGE_LIMIT; + let mut enriched_stories = Vec::with_capacity(stories.len()); + + for (idx, story) in stories.into_iter().enumerate() { + let story_meta = meta.iter().find(|m| m.story_id == story.id); + let tags: Vec<Category> = story_meta + .map(|m| m.tags.iter().map(|name| Category { id: 0, name: Some(name.clone()) }).collect()) + .unwrap_or_default(); + let author_username = story_meta.and_then(|m| m.author_username.clone()); + + let time_ago = database::time_ago(&story.published); + let user_voted = voted_ids.contains(&story.id); + let rank = base_rank + idx + 1; + + enriched_stories.push(StoryWithMeta::from_story( + story, + tags, + author_username, + time_ago, + user_voted, + rank, + )); + } + + Ok((enriched_stories, reached_end)) + }).await?; + + let is_logged_in = user_id.is_some(); let tag_template = TagTemplate { - tag_name: tag_name.clone(), - stories: enriched_stories, + tag_name, + stories, page: query.page, reached_end, is_logged_in, diff --git a/src/handlers/user.rs b/src/handlers/user.rs @@ -6,12 +6,12 @@ use axum::{ use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; -use crate::db::{CommentWithMeta, StoryWithMeta}; +use crate::db::{CommentWithMeta, StoryWithMeta, UserProfile}; +use crate::database::{self, TARGET_TYPE_STORY}; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, UserTemplate}; -use crate::trailbase::{self, TARGET_TYPE_STORY}; #[derive(Debug, Deserialize)] pub struct UserQuery { @@ -29,71 +29,78 @@ pub async fn show_user( Path(username): Path<String>, Query(query): Query<UserQuery>, ) -> Result<Html<String>, AppError> { - let tokens = get_tokens_from_cookies(&jar); - let client = match &tokens { - Some(t) => trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, t)?, - None => trailbase::new_client(&state.http_client, &state.trailbase_url)?, - }; + let current_user_id = get_user_id_from_cookies(&jar, &state.jwt_secret); + let tab = query.tab.clone(); + let username_clone = username.clone(); + + let (profile, submissions, comments) = state.db_query(move |db| { + // Get user by username + let user = db.get_user_by_username(&username_clone)? + .ok_or_else(|| AppError::NotFound(format!("User '{}' not found", username_clone)))?; + let profile: UserProfile = user.into(); + + let mut submissions: Vec<StoryWithMeta> = Vec::new(); + let mut comments: Vec<CommentWithMeta> = Vec::new(); + + let user_id_num: i64 = profile.user_id.parse().unwrap_or(0); + + match tab.as_str() { + "submissions" => { + let stories = db.get_stories_by_user(user_id_num, 30, 0)?; + + let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); + let voted_ids = if let Some(uid) = current_user_id { + if !story_ids.is_empty() { + db.get_user_votes(uid, TARGET_TYPE_STORY, &story_ids) + .unwrap_or_default() + } else { + vec![] + } + } else { + vec![] + }; - // Get user profile - let profile = trailbase::get_user_profile_by_username(&client, &username) - .await? - .ok_or_else(|| AppError::NotFound(format!("User '{}' not found", username)))?; - - // Fetch content based on tab - let mut submissions: Vec<StoryWithMeta> = Vec::new(); - let mut comments: Vec<CommentWithMeta> = Vec::new(); - - match query.tab.as_str() { - "submissions" => { - let stories = trailbase::get_user_stories(&client, &profile.user_id, 30, 0).await?; - - // Get voted story IDs if logged in - let story_ids: Vec<i64> = stories.iter().map(|s| s.id).collect(); - let voted_ids = if tokens.is_some() && !story_ids.is_empty() { - trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &story_ids) - .await - .inspect_err(|e| tracing::error!("Failed to get votes: {}", e)) - .unwrap_or_default() - } else { - vec![] - }; - - for (idx, story) in stories.into_iter().enumerate() { - let tags = trailbase::get_categories_for_story(&client, story.id) - .await - .inspect_err(|e| tracing::error!("Failed to get tags for story {}: {}", story.id, e)) - .unwrap_or_default(); - let time_ago = trailbase::time_ago(&story.published); - let user_voted = voted_ids.contains(&story.id); - - submissions.push(StoryWithMeta::from_story( - story, - tags, - Some(username.clone()), - time_ago, - user_voted, - idx + 1, - )); + // Batch fetch tags + let meta = db.enrich_stories(&story_ids).unwrap_or_default(); + + for (idx, story) in stories.into_iter().enumerate() { + let story_meta = meta.iter().find(|m| m.story_id == story.id); + let tags = story_meta + .map(|m| m.tags.iter().map(|name| database::Category { id: 0, name: Some(name.clone()) }).collect()) + .unwrap_or_default(); + let time_ago = database::time_ago(&story.published); + let user_voted = voted_ids.contains(&story.id); + + submissions.push(StoryWithMeta::from_story( + story, + tags, + Some(profile.username.clone()), + time_ago, + user_voted, + idx + 1, + )); + } } - } - "comments" => { - let user_comments = trailbase::get_user_comments(&client, &profile.user_id, 30, 0).await?; - - for comment in user_comments { - let time_ago = trailbase::time_ago(&comment.created_at); - comments.push(CommentWithMeta::from_comment( - comment, - Some(username.clone()), - time_ago, - false, - )); + "comments" => { + let user_comments = db.get_comments_by_user(user_id_num, 30, 0)?; + + for comment in user_comments { + let time_ago = database::time_ago(&comment.created_at); + comments.push(CommentWithMeta::from_comment( + comment, + Some(profile.username.clone()), + time_ago, + false, + )); + } } + _ => {} } - _ => {} - } - let is_logged_in = tokens.is_some(); + Ok((profile, submissions, comments)) + }).await?; + + let is_logged_in = current_user_id.is_some(); let user_template = UserTemplate { profile, diff --git a/src/handlers/vote.rs b/src/handlers/vote.rs @@ -5,29 +5,12 @@ use axum::{ }; use axum_extra::extract::cookie::CookieJar; +use crate::database::{TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; use crate::error::AppError; -use crate::handlers::auth::get_tokens_from_cookies; +use crate::handlers::auth::get_user_id_from_cookies; use crate::sse; use crate::state::AppState; use crate::templates::VoteButtonTemplate; -use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; - -/// Toggle a vote: try to create, if it fails (unique constraint), delete instead. -/// Returns whether the user now has an active vote. -async fn toggle_vote( - client: &trailbase_client::Client, - target_type: i64, - target_id: i64, -) -> Result<bool, AppError> { - match trailbase::vote(client, target_type, target_id).await { - Ok(()) => Ok(true), - Err(_) => { - // Vote already exists (unique constraint violation) — remove it - trailbase::unvote(client, target_type, target_id).await?; - Ok(false) - } - } -} fn render_vote_button( target_type: &str, @@ -52,16 +35,14 @@ pub async fn vote_story( jar: CookieJar, Path(story_id): Path<i64>, ) -> Result<Response, AppError> { - let tokens = get_tokens_from_cookies(&jar) + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens)?; - let voted = toggle_vote(&client, TARGET_TYPE_STORY, story_id).await?; - - let new_score = trailbase::get_story(&client, story_id) - .await? - .map(|s| s.score) - .unwrap_or(0); + let (voted, new_score) = state.db_query(move |db| { + let voted = db.toggle_vote(user_id, TARGET_TYPE_STORY, story_id)?; + let new_score = db.get_story_score(story_id)?; + Ok((voted, new_score)) + }).await?; render_vote_button("story", story_id, new_score, voted) } @@ -72,24 +53,14 @@ pub async fn vote_comment( jar: CookieJar, Path(comment_id): Path<i64>, ) -> Result<Response, AppError> { - let tokens = get_tokens_from_cookies(&jar) + let user_id = get_user_id_from_cookies(&jar, &state.jwt_secret) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; - let client = trailbase::client_with_tokens(&state.http_client, &state.trailbase_url, &tokens)?; - let voted = toggle_vote(&client, TARGET_TYPE_COMMENT, comment_id).await?; - - let args = trailbase_client::ListArguments::new() - .with_filters(trailbase_client::Filter::new( - "id", - trailbase_client::CompareOp::Equal, - comment_id.to_string(), - )); - let response = client - .records("comment") - .list::<trailbase::Comment>(args) - .await - .map_err(trailbase::ClientError::from)?; - let new_score = response.records.first().map(|c| c.score).unwrap_or(0); + let (voted, new_score) = state.db_query(move |db| { + let voted = db.toggle_vote(user_id, TARGET_TYPE_COMMENT, comment_id)?; + let new_score = db.get_comment_score(comment_id)?; + Ok((voted, new_score)) + }).await?; render_vote_button("comment", comment_id, new_score, voted) } diff --git a/src/main.rs b/src/main.rs @@ -1,4 +1,6 @@ +mod auth; mod config; +mod database; mod db; mod error; mod handlers; @@ -6,10 +8,9 @@ mod routes; mod sse; mod state; mod templates; -mod trailbase; -mod trailbase_types; use config::Config; +use database::Database; use state::AppState; #[tokio::main] @@ -17,9 +18,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let config = Config::from_env(); eprintln!("Starting server with DEBUG={}", config.debug); - eprintln!("Trailbase client configured at {}", config.trailbase_url); + eprintln!("Database path: {}", config.db_path); - let state = AppState::new(&config); + let db = Database::new(&config.db_path)?; + let state = AppState::new(&config, db); let app = routes::create_router(state); diff --git a/src/routes.rs b/src/routes.rs @@ -1,7 +1,6 @@ use axum::{ Router, body::Body, - extract::{Request, State}, http::{StatusCode, header}, response::{IntoResponse, Response}, routing::{get, post}, @@ -105,9 +104,6 @@ pub fn create_router(state: AppState) -> Router { .route("/resend-verification", get(handlers::show_resend_verification)) .route("/resend-verification", post(handlers::resend_verification)) - // Reverse proxy to TrailBase for /api/* (SSE subscriptions, etc.) - .route("/api/{*path}", axum::routing::any(proxy_to_trailbase)) - // Static files .route("/static/style.css", get(serve_style)) .route("/static/datastar.js", get(serve_datastar)) @@ -115,36 +111,3 @@ pub fn create_router(state: AppState) -> Router { .route("/static/{*path}", get(static_404)) .with_state(state) } - -async fn proxy_to_trailbase( - State(state): State<AppState>, - request: Request, -) -> Response { - let path = request.uri().path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or(request.uri().path()); - - let url = format!("{}{}", state.trailbase_url.trim_end_matches('/'), path); - - let response = match state.http_client.get(&url).send().await { - Ok(resp) => resp, - Err(_) => { - return Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("TrailBase unavailable")) - .unwrap(); - } - }; - - let status = response.status(); - let mut builder = Response::builder().status(status); - - // Forward content-type and SSE-relevant headers - for (name, value) in response.headers() { - builder = builder.header(name, value); - } - - // Stream the body through for SSE support - let body = Body::from_stream(response.bytes_stream()); - builder.body(body).unwrap() -} diff --git a/src/state.rs b/src/state.rs @@ -1,16 +1,35 @@ +use std::sync::Arc; + use crate::config::Config; +use crate::database::Database; +use crate::error::AppError; #[derive(Clone)] pub struct AppState { - pub trailbase_url: String, - pub http_client: reqwest::Client, + pub db: Arc<Database>, + pub jwt_secret: String, + pub comment_tx: tokio::sync::broadcast::Sender<i64>, } impl AppState { - pub fn new(config: &Config) -> Self { + pub fn new(config: &Config, db: Database) -> Self { + let (comment_tx, _) = tokio::sync::broadcast::channel(64); Self { - trailbase_url: config.trailbase_url.clone(), - http_client: reqwest::Client::new(), + db: Arc::new(db), + jwt_secret: config.jwt_secret.clone(), + comment_tx, } } + + /// Run a blocking database query on the Tokio blocking thread pool. + pub async fn db_query<F, T>(&self, f: F) -> Result<T, AppError> + where + F: FnOnce(&Database) -> Result<T, AppError> + Send + 'static, + T: Send + 'static, + { + let db = self.db.clone(); + tokio::task::spawn_blocking(move || f(&db)) + .await + .map_err(|e| AppError::Database(e.to_string()))? + } } diff --git a/src/templates.rs b/src/templates.rs @@ -1,7 +1,7 @@ use askama::Template; use crate::db::{Category, CommentWithMeta, StoryWithMeta, UserProfile}; -use crate::trailbase::SearchResult; +use crate::database::SearchResult; #[derive(Template)] #[template(path = "application.html")] diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -1,912 +0,0 @@ -use std::borrow::Cow; - -use reqwest::header::HeaderMap; -use reqwest::Method; -use serde::{Deserialize, Serialize}; -use trailbase_client::{Client, ClientOptions, CompareOp, Filter, ListArguments, Pagination, Tokens, Transport}; - -pub use crate::trailbase_types::{Category, Story, Comment, Vote, UserProfile, StoryCategory}; - -// Target type constants for votes -pub const TARGET_TYPE_STORY: i64 = 1; -pub const TARGET_TYPE_COMMENT: i64 = 2; - -/// Transport that shares a single reqwest::Client connection pool across -/// all TrailBase clients, avoiding per-request TCP connection setup. -struct SharedTransport { - client: reqwest::Client, - url: url::Url, -} - -impl SharedTransport { - fn new(client: reqwest::Client, url: url::Url) -> Self { - Self { client, url } - } -} - -#[async_trait::async_trait] -impl Transport for SharedTransport { - async fn fetch( - &self, - path: &str, - headers: HeaderMap, - method: Method, - body: Option<Vec<u8>>, - query_params: Option<&[(Cow<'static, str>, Cow<'static, str>)]>, - ) -> Result<http::Response<reqwest::Body>, trailbase_client::Error> { - let mut url = self.url.clone(); - url.set_path(path); - - if let Some(query_params) = query_params { - let mut params = url.query_pairs_mut(); - for (key, value) in query_params { - params.append_pair(key, value); - } - } - - let mut builder = self.client.request(method, url).headers(headers); - if let Some(body) = body { - builder = builder.body(body); - } - let request = builder.build()?; - - Ok(self.client.execute(request).await?.into()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ClientError { - #[error("TrailBase error: {0}")] - TrailBase(#[from] trailbase_client::Error), - #[error("Auth error: {0}")] - Auth(String), - #[error("Email verification required")] - EmailVerificationRequired, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub id: i64, - pub title: String, - pub text: String, - pub score: i64, - pub comment_count: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoryMeta { - pub story_id: i64, - pub author_username: Option<String>, - pub tags: Vec<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserMapping { - pub user_id: String, - pub username: String, -} - -pub async fn enrich_comment_authors( - http_client: &reqwest::Client, - trailbase_url: &str, - user_ids: &[&str], -) -> Result<Vec<UserMapping>, ClientError> { - if user_ids.is_empty() { - return Ok(vec![]); - } - let ids_str = user_ids.join(","); - let base = trailbase_url.trim_end_matches('/'); - let url = format!("{}/enrich_comments?user_ids={}", base, ids_str); - let response = http_client - .get(&url) - .send() - .await - .map_err(|e| ClientError::Auth(format!("Enrich comments request failed: {}", e)))?; - response - .json() - .await - .map_err(|e| ClientError::Auth(format!("Enrich comments parse failed: {}", e))) -} - -pub async fn enrich_stories( - http_client: &reqwest::Client, - trailbase_url: &str, - story_ids: &[i64], -) -> Result<Vec<StoryMeta>, ClientError> { - if story_ids.is_empty() { - return Ok(vec![]); - } - let ids_str: String = story_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); - let base = trailbase_url.trim_end_matches('/'); - let url = format!("{}/enrich?ids={}", base, ids_str); - let response = http_client - .get(&url) - .send() - .await - .map_err(|e| ClientError::Auth(format!("Enrich request failed: {}", e)))?; - response - .json() - .await - .map_err(|e| ClientError::Auth(format!("Enrich parse failed: {}", e))) -} - -/// Tokens stored in cookies for authenticated requests -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredTokens { - pub auth_token: String, - pub refresh_token: Option<String>, -} - -/// Extract user ID from JWT auth token -pub fn extract_user_id_from_token(auth_token: &str) -> Option<String> { - // JWT format: header.payload.signature - let parts: Vec<&str> = auth_token.split('.').collect(); - if parts.len() != 3 { - return None; - } - - // Decode the payload (second part) - JWT uses URL-safe base64 without padding - let payload = base64_decode_url_safe(parts[1])?; - let payload_str = String::from_utf8(payload).ok()?; - - // Parse as JSON and extract "sub" claim - let json: serde_json::Value = serde_json::from_str(&payload_str).ok()?; - json.get("sub").and_then(|v| v.as_str()).map(|s| s.to_string()) -} - -/// URL-safe base64 decode (handles missing padding) -fn base64_decode_url_safe(input: &str) -> Option<Vec<u8>> { - // Add padding if needed - let padded = match input.len() % 4 { - 2 => format!("{}==", input), - 3 => format!("{}=", input), - _ => input.to_string(), - }; - - // Convert URL-safe to standard base64 - let standard: String = padded - .chars() - .map(|c| match c { - '-' => '+', - '_' => '/', - c => c, - }) - .collect(); - - // Use standard library base64 decoding via a simple implementation - base64_decode(&standard) -} - -/// Simple base64 decoder -fn base64_decode(input: &str) -> Option<Vec<u8>> { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let mut output = Vec::new(); - let mut buffer: u32 = 0; - let mut bits = 0; - - for c in input.bytes() { - if c == b'=' { - break; - } - let val = ALPHABET.iter().position(|&x| x == c)? as u32; - buffer = (buffer << 6) | val; - bits += 6; - - if bits >= 8 { - bits -= 8; - output.push((buffer >> bits) as u8); - buffer &= (1 << bits) - 1; - } - } - - Some(output) -} - -fn make_transport(http_client: &reqwest::Client, base_url: &str) -> Result<Box<dyn Transport + Send + Sync>, ClientError> { - let url: url::Url = base_url.parse().map_err(|e| ClientError::Auth(format!("Invalid URL: {}", e)))?; - Ok(Box::new(SharedTransport::new(http_client.clone(), url))) -} - -/// Create a new unauthenticated client using the shared connection pool -pub fn new_client(http_client: &reqwest::Client, base_url: &str) -> Result<Client, ClientError> { - let opts = ClientOptions { - tokens: None, - transport: Some(make_transport(http_client, base_url)?), - }; - Ok(Client::new(base_url, Some(opts))?) -} - -/// Create a client with existing tokens using the shared connection pool -pub fn client_with_tokens(http_client: &reqwest::Client, base_url: &str, tokens: &StoredTokens) -> Result<Client, ClientError> { - let opts = ClientOptions { - tokens: Some(Tokens { - auth_token: tokens.auth_token.clone(), - refresh_token: tokens.refresh_token.clone(), - csrf_token: None, - }), - transport: Some(make_transport(http_client, base_url)?), - }; - Ok(Client::new(base_url, Some(opts))?) -} - -// ============================================================================ -// Story functions -// ============================================================================ - -/// Get stories ordered by published date (newest first) -pub async fn get_stories_new( - client: &Client, - limit: usize, - offset: usize, -) -> Result<Vec<Story>, ClientError> { - let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) - .with_order(&["-published"]); - - let response = client.records("story").list::<Story>(args).await?; - Ok(response.records) -} - -/// Get stories ordered by score (highest first) -pub async fn get_stories_top( - client: &Client, - limit: usize, - offset: usize, -) -> Result<Vec<Story>, ClientError> { - let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) - .with_order(&["-score", "-published"]); - - let response = client.records("story").list::<Story>(args).await?; - Ok(response.records) -} - -/// Get stories with hot ranking (client-side calculation) -pub async fn get_stories_hot( - client: &Client, - limit: usize, - offset: usize, -) -> Result<Vec<Story>, ClientError> { - // Fetch more than needed for client-side ranking - let fetch_limit = (limit + offset) * 2; - let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(fetch_limit)) - .with_order(&["-published"]); - - let response = client.records("story").list::<Story>(args).await?; - let mut stories = response.records; - - // Sort by hot score - stories.sort_by(|a, b| { - let score_a = hot_score(a.score, &a.published); - let score_b = hot_score(b.score, &b.published); - score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) - }); - - // Apply offset and limit - let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect(); - Ok(result) -} - -/// Get a single story by ID -pub async fn get_story(client: &Client, id: i64) -> Result<Option<Story>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("id", CompareOp::Equal, id.to_string())); - - let response = client.records("story").list::<Story>(args).await?; - Ok(response.records.into_iter().next()) -} - -/// Create a new story -#[derive(Debug, Serialize)] -pub struct CreateStory { - pub url: Option<String>, - pub title: String, - pub text: String, - pub domain: Option<String>, - pub published: String, - pub language: String, -} - -#[derive(Debug, Serialize)] -struct CreateStoryWithUser { - url: Option<String>, - title: String, - text: String, - domain: Option<String>, - published: String, - language: String, - created_by: String, -} - -pub async fn create_story(client: &Client, story: CreateStory) -> Result<Story, ClientError> { - let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?; - let user_id = extract_user_id_from_token(&tokens.auth_token) - .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?; - let story_with_user = CreateStoryWithUser { - url: story.url, - title: story.title, - text: story.text, - domain: story.domain, - published: story.published, - language: story.language, - created_by: user_id, - }; - let id_str = client.records("story").create(&story_with_user).await?; - let id: i64 = id_str.parse().unwrap_or(0); - get_story(client, id).await?.ok_or_else(|| { - ClientError::Auth("Failed to retrieve created story".to_string()) - }) -} - -/// Get stories by tag/category -pub async fn get_stories_by_tag( - http_client: &reqwest::Client, - trailbase_url: &str, - client: &Client, - category_id: i64, - limit: usize, - offset: usize, -) -> Result<Vec<Story>, ClientError> { - // Use WASM endpoint for paginated JOIN query - let base = trailbase_url.trim_end_matches('/'); - let url = format!( - "{}/stories_by_tag?category_id={}&limit={}&offset={}", - base, category_id, limit, offset - ); - let story_ids: Vec<i64> = http_client - .get(&url) - .send() - .await - .map_err(|e| ClientError::Auth(format!("stories_by_tag request failed: {}", e)))? - .json() - .await - .map_err(|e| ClientError::Auth(format!("stories_by_tag parse failed: {}", e)))?; - - if story_ids.is_empty() { - return Ok(vec![]); - } - - // Fetch full story records by ID - let filters: Vec<Filter> = story_ids - .iter() - .map(|id| Filter::new("id", CompareOp::Equal, id.to_string())) - .collect(); - - let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(limit)) - .with_order(&["-published"]) - .with_filters(filters); - - let response = client.records("story").list::<Story>(args).await?; - Ok(response.records) -} - -// ============================================================================ -// Comment functions -// ============================================================================ - -/// Get all comments for a story, ordered by path (thread order) -pub async fn get_comments_for_story( - client: &Client, - story_id: i64, -) -> Result<Vec<Comment>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("story_id", CompareOp::Equal, story_id.to_string())) - .with_order(&["path"]) - .with_pagination(Pagination::new().with_limit(1000)); - - let response = client.records("comment").list::<Comment>(args).await?; - Ok(response.records) -} - -/// Create a new comment -#[derive(Debug, Serialize)] -pub struct CreateComment { - pub story_id: i64, - pub parent_id: Option<i64>, - pub path: String, - pub depth: i64, - pub text: String, -} - -#[derive(Debug, Serialize)] -struct CreateCommentWithUser { - story_id: i64, - parent_id: Option<i64>, - path: String, - depth: i64, - text: String, - created_by: String, -} - -pub async fn create_comment(client: &Client, comment: CreateComment) -> Result<Comment, ClientError> { - let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?; - let user_id = extract_user_id_from_token(&tokens.auth_token) - .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?; - - let comment_with_user = CreateCommentWithUser { - story_id: comment.story_id, - parent_id: comment.parent_id, - path: comment.path, - depth: comment.depth, - text: comment.text, - created_by: user_id, - }; - - let id_str = client.records("comment").create(&comment_with_user).await?; - let id: i64 = id_str.parse().unwrap_or(0); - - // Fetch the created comment - let args = ListArguments::new() - .with_filters(Filter::new("id", CompareOp::Equal, id.to_string())); - let response = client.records("comment").list::<Comment>(args).await?; - response.records.into_iter().next().ok_or_else(|| { - ClientError::Auth("Failed to retrieve created comment".to_string()) - }) -} - -/// Calculate the path for a new comment -pub async fn calculate_comment_path( - client: &Client, - story_id: i64, - parent_id: Option<i64>, -) -> Result<(String, i64), ClientError> { - match parent_id { - Some(pid) => { - // Get parent comment - let args = ListArguments::new() - .with_filters(Filter::new("id", CompareOp::Equal, pid.to_string())); - let response = client.records("comment").list::<Comment>(args).await?; - - if let Some(parent) = response.records.into_iter().next() { - // Count siblings to determine new ID - let sibling_args = ListArguments::new() - .with_filters(Filter::new("parent_id", CompareOp::Equal, pid.to_string())); - let siblings = client.records("comment").list::<Comment>(sibling_args).await?; - let sibling_count = siblings.records.len() + 1; - - let path = format!("{}.{}", parent.path, sibling_count); - let depth = parent.depth + 1; - Ok((path, depth)) - } else { - // Parent not found, treat as root - let root_args = ListArguments::new() - .with_filters(vec![ - Filter::new("story_id", CompareOp::Equal, story_id.to_string()), - Filter::new("depth", CompareOp::Equal, "0".to_string()), - ]); - let roots = client.records("comment").list::<Comment>(root_args).await?; - let root_count = roots.records.len() + 1; - Ok((root_count.to_string(), 0)) - } - } - None => { - // Root level comment - let root_args = ListArguments::new() - .with_filters(vec![ - Filter::new("story_id", CompareOp::Equal, story_id.to_string()), - Filter::new("depth", CompareOp::Equal, "0".to_string()), - ]); - let roots = client.records("comment").list::<Comment>(root_args).await?; - let root_count = roots.records.len() + 1; - Ok((root_count.to_string(), 0)) - } - } -} - -// ============================================================================ -// Vote functions -// ============================================================================ - -/// Create a vote -pub async fn vote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> { - let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?; - let user_id = extract_user_id_from_token(&tokens.auth_token) - .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?; - - // Include user_id in vote - #[derive(Debug, serde::Serialize)] - struct CreateVoteWithUser { - user_id: String, - target_type: i64, - target_id: i64, - } - - let vote_data = CreateVoteWithUser { - user_id, - target_type, - target_id, - }; - - client.records("vote").create(&vote_data).await?; - Ok(()) -} - -/// Remove a vote -pub async fn unvote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> { - // First find the vote - let args = ListArguments::new() - .with_filters(vec![ - Filter::new("target_type", CompareOp::Equal, target_type.to_string()), - Filter::new("target_id", CompareOp::Equal, target_id.to_string()), - ]); - - let response = client.records("vote").list::<Vote>(args).await?; - if let Some(vote) = response.records.into_iter().next() { - client.records("vote").delete(&vote.id.to_string()).await?; - } - Ok(()) -} - -/// Check if user has voted on targets -pub async fn get_user_votes( - client: &Client, - target_type: i64, - target_ids: &[i64], -) -> Result<Vec<i64>, ClientError> { - if target_ids.is_empty() { - return Ok(vec![]); - } - - let user_id = client - .tokens() - .and_then(|t| extract_user_id_from_token(&t.auth_token)) - .ok_or_else(|| ClientError::Auth("Not authenticated".into()))?; - - use trailbase_client::ValueOrFilterGroup; - - // Build: user_id=X AND target_type=T AND (target_id=A OR target_id=B OR ...) - let id_or = ValueOrFilterGroup::Or( - target_ids - .iter() - .map(|id| ValueOrFilterGroup::Filter(Filter::new("target_id", CompareOp::Equal, id.to_string()))) - .collect(), - ); - - let combined = ValueOrFilterGroup::And(vec![ - ValueOrFilterGroup::Filter(Filter::new("user_id", CompareOp::Equal, &user_id)), - ValueOrFilterGroup::Filter(Filter::new("target_type", CompareOp::Equal, target_type.to_string())), - id_or, - ]); - - let args = ListArguments::new().with_filters(combined); - - let response = client.records("vote").list::<Vote>(args).await?; - let voted_ids: Vec<i64> = response.records.iter().map(|v| v.target_id).collect(); - Ok(voted_ids) -} - -// ============================================================================ -// User Profile functions -// ============================================================================ - -/// Get user profile by username -pub async fn get_user_profile_by_username( - client: &Client, - username: &str, -) -> Result<Option<UserProfile>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("username", CompareOp::Equal, username)); - - let response = client.records("user_profile").list::<UserProfile>(args).await?; - Ok(response.records.into_iter().next()) -} - -/// Get user profile by user ID -pub async fn get_user_profile_by_id( - client: &Client, - user_id: &str, -) -> Result<Option<UserProfile>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("user_id", CompareOp::Equal, user_id)); - - let response = client.records("user_profile").list::<UserProfile>(args).await?; - Ok(response.records.into_iter().next()) -} - -/// Create a new user profile -#[derive(Debug, Serialize)] -pub struct CreateUserProfile { - pub username: String, - pub user_id: String, -} - -pub async fn create_user_profile( - client: &Client, - username: &str, -) -> Result<UserProfile, ClientError> { - let user_id = client - .tokens() - .and_then(|t| extract_user_id_from_token(&t.auth_token)) - .ok_or_else(|| ClientError::Auth("Not authenticated".into()))?; - - let profile = CreateUserProfile { - username: username.to_string(), - user_id, - }; - client.records("user_profile").create(&profile).await?; - get_user_profile_by_username(client, username).await?.ok_or_else(|| { - ClientError::Auth("Failed to retrieve created profile".to_string()) - }) -} - -/// Get stories created by a user -pub async fn get_user_stories( - client: &Client, - user_id: &str, - limit: usize, - offset: usize, -) -> Result<Vec<Story>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("created_by", CompareOp::Equal, user_id)) - .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) - .with_order(&["-published"]); - - let response = client.records("story").list::<Story>(args).await?; - Ok(response.records) -} - -/// Get comments created by a user -pub async fn get_user_comments( - client: &Client, - user_id: &str, - limit: usize, - offset: usize, -) -> Result<Vec<Comment>, ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("created_by", CompareOp::Equal, user_id)) - .with_pagination(Pagination::new().with_limit(limit).with_offset(offset)) - .with_order(&["-created_at"]); - - let response = client.records("comment").list::<Comment>(args).await?; - Ok(response.records) -} - -// ============================================================================ -// Category/Tag functions -// ============================================================================ - -pub async fn get_categories(client: &Client, limit: usize) -> Result<Vec<Category>, ClientError> { - let args = ListArguments::new() - .with_pagination(Pagination::new().with_limit(limit)); - - let response = client.records("category").list::<Category>(args).await?; - Ok(response.records) -} - -pub async fn get_categories_for_story( - client: &Client, - story_id: i64, -) -> Result<Vec<Category>, ClientError> { - // Get category IDs from junction table - let junction_args = ListArguments::new() - .with_filters(Filter::new("news_item_id", CompareOp::Equal, story_id.to_string())); - - let junction: trailbase_client::ListResponse<serde_json::Value> = - client.records("story_category").list(junction_args).await?; - - let category_ids: Vec<i64> = junction - .records - .iter() - .filter_map(|v| v.get("category_id").and_then(|id| id.as_i64())) - .collect(); - - if category_ids.is_empty() { - return Ok(vec![]); - } - - // Build filters for categories (OR of all IDs) - let filters: Vec<Filter> = category_ids - .iter() - .map(|id| Filter::new("id", CompareOp::Equal, id.to_string())) - .collect(); - - let args = ListArguments::new() - .with_filters(filters); - - let response = client.records("category").list::<Category>(args).await?; - Ok(response.records) -} - -/// Add a tag to a story -#[derive(Debug, Serialize)] -struct CreateStoryCategory { - news_item_id: i64, - category_id: i64, -} - -pub async fn add_tag_to_story( - client: &Client, - story_id: i64, - category_id: i64, -) -> Result<(), ClientError> { - let junction = CreateStoryCategory { - news_item_id: story_id, - category_id, - }; - client.records("story_category").create(&junction).await?; - Ok(()) -} - -/// Update a story's title, url, text, and domain -#[derive(Debug, Serialize)] -pub struct UpdateStory { - pub url: Option<String>, - pub title: String, - pub text: String, - pub domain: Option<String>, -} - -pub async fn update_story(client: &Client, id: i64, update: UpdateStory) -> Result<(), ClientError> { - client.records("story").update(id.to_string(), &update).await?; - Ok(()) -} - -/// Remove all tags from a story -pub async fn remove_all_tags_from_story( - client: &Client, - story_id: i64, -) -> Result<(), ClientError> { - let args = ListArguments::new() - .with_filters(Filter::new("news_item_id", CompareOp::Equal, story_id.to_string())); - let response: trailbase_client::ListResponse<StoryCategory> = - client.records("story_category").list(args).await?; - for sc in response.records { - client.records("story_category").delete(&sc.id.to_string()).await?; - } - Ok(()) -} - - -// ============================================================================ -// Auth functions -// ============================================================================ - -pub async fn login(client: &Client, email: &str, password: &str) -> Result<StoredTokens, ClientError> { - client.login(email, password).await?; - - let tokens = client.tokens().ok_or_else(|| { - ClientError::Auth("No tokens after login".into()) - })?; - - Ok(StoredTokens { - auth_token: tokens.auth_token, - refresh_token: tokens.refresh_token, - }) -} - -pub async fn register(client: &Client, http_client: &reqwest::Client, email: &str, password: &str) -> Result<StoredTokens, ClientError> { - #[derive(Serialize)] - struct RegisterRequest<'a> { - email: &'a str, - password: &'a str, - password_repeat: &'a str, - } - - let url = format!("{}api/auth/v1/register", client.base_url()); - let response = http_client - .post(&url) - .json(&RegisterRequest { - email, - password, - password_repeat: password, - }) - .send() - .await - .map_err(|e| ClientError::Auth(format!("HTTP error: {}", e)))?; - - let status = response.status(); - if !status.is_success() && !status.is_redirection() { - let body = response.text().await.unwrap_or_default(); - return Err(ClientError::Auth(format!("Registration failed ({}): {}", status, body))); - } - - // If redirected (303), registration succeeded but email verification may be required. - // Try to log in — if email verification is enabled, this may fail. - if let Err(_) = client.login(email, password).await { - return Err(ClientError::EmailVerificationRequired); - } - - let tokens = client.tokens().ok_or_else(|| { - ClientError::Auth("No tokens after registration".into()) - })?; - - Ok(StoredTokens { - auth_token: tokens.auth_token, - refresh_token: tokens.refresh_token, - }) -} - -#[allow(dead_code)] -pub async fn refresh_tokens(client: &Client) -> Result<StoredTokens, ClientError> { - client.refresh().await?; - - let tokens = client.tokens().ok_or_else(|| { - ClientError::Auth("No tokens after refresh".into()) - })?; - - Ok(StoredTokens { - auth_token: tokens.auth_token, - refresh_token: tokens.refresh_token, - }) -} - -// ============================================================================ -// Helper functions -// ============================================================================ - -/// Calculate hot score for ranking (HN-style algorithm) -pub fn hot_score(points: i64, published: &str) -> f64 { - let age_hours = hours_since(published); - (points as f64) / (age_hours + 2.0).powf(1.8) -} - -/// Calculate hours since a timestamp -fn hours_since(timestamp: &str) -> f64 { - use chrono::{DateTime, Utc}; - - let published = timestamp - .parse::<DateTime<Utc>>() - .or_else(|_| { - // Try parsing as naive datetime - chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") - .map(|dt| dt.and_utc()) - }) - .unwrap_or_else(|_| Utc::now()); - - let now = Utc::now(); - let duration = now.signed_duration_since(published); - duration.num_minutes() as f64 / 60.0 -} - -/// Format a timestamp as "X ago" string -pub fn time_ago(timestamp: &str) -> String { - use chrono::{DateTime, Utc}; - - let published = timestamp - .parse::<DateTime<Utc>>() - .or_else(|_| { - chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") - .map(|dt| dt.and_utc()) - }) - .unwrap_or_else(|_| Utc::now()); - - let now = Utc::now(); - let duration = now.signed_duration_since(published); - - let minutes = duration.num_minutes(); - let hours = duration.num_hours(); - let days = duration.num_days(); - - if days > 0 { - if days == 1 { - "1 day ago".to_string() - } else { - format!("{} days ago", days) - } - } else if hours > 0 { - if hours == 1 { - "1 hour ago".to_string() - } else { - format!("{} hours ago", hours) - } - } else if minutes > 0 { - if minutes == 1 { - "1 minute ago".to_string() - } else { - format!("{} minutes ago", minutes) - } - } else { - "just now".to_string() - } -} - -/// Extract domain from URL -pub fn extract_domain(url: &str) -> Option<String> { - url::Url::parse(url) - .ok() - .and_then(|u| u.host_str().map(|h| h.to_string())) - .map(|h| h.trim_start_matches("www.").to_string()) -} diff --git a/src/trailbase_types.rs b/src/trailbase_types.rs @@ -1,64 +0,0 @@ -//! Auto-generated types from Trailbase schema. -//! Run `./scripts/generate-types.sh` to regenerate. - -use serde::{Serialize, Deserialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Story { - pub author: Option<String>, - pub comment_count: i64, - pub created_by: Option<String>, - pub domain: Option<String>, - pub id: i64, - pub language: String, - pub published: String, - pub score: i64, - pub text: String, - pub title: String, - pub url: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Comment { - pub created_at: String, - pub created_by: String, - pub depth: i64, - pub id: i64, - pub parent_id: Option<i64>, - pub path: String, - pub score: i64, - pub story_id: i64, - pub text: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Vote { - pub created_at: String, - pub id: i64, - pub target_id: i64, - pub target_type: i64, - pub user_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserProfile { - pub about: Option<String>, - pub created_at: String, - pub id: i64, - pub karma: i64, - pub user_id: String, - pub username: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Category { - pub id: i64, - pub name: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoryCategory { - pub category_id: i64, - pub id: i64, - pub news_item_id: i64, -}