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:
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,
-}