Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
c50dc78c9b | |||
6bfd298455 | |||
ad6c9b7095 | |||
1fa52e74e6 | |||
13159e5d78 |
24 changed files with 1060 additions and 140 deletions
|
@ -1,2 +1,8 @@
|
||||||
postgres-data
|
target/
|
||||||
dragonfly-data
|
postgres-data/
|
||||||
|
dragonfly-data/
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
*.log
|
21
.env.example
Normal file
21
.env.example
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Server Configuration
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
|
||||||
|
DB_MAX_CONNECTIONS=10
|
||||||
|
DB_CONNECT_TIMEOUT=30
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://dragonfly:6379
|
||||||
|
REDIS_POOL_SIZE=5
|
||||||
|
REDIS_CONNECT_TIMEOUT=10
|
||||||
|
|
||||||
|
# Discord OAuth Configuration
|
||||||
|
CLIENT_ID=your_discord_client_id
|
||||||
|
CLIENT_SECRET=your_discord_client_secret
|
||||||
|
REDIRECT_URI=https://your.domain/auth/discord/callback
|
||||||
|
|
||||||
|
# Logging (optional)
|
||||||
|
RUST_LOG=info,timezone_db=debug
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,4 +2,4 @@
|
||||||
.sqlx
|
.sqlx
|
||||||
dragonfly-data
|
dragonfly-data
|
||||||
postgres-data
|
postgres-data
|
||||||
.env
|
.env
|
162
Cargo.lock
generated
162
Cargo.lock
generated
|
@ -32,6 +32,21 @@ version = "0.2.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android-tzdata"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.23"
|
version = "0.4.23"
|
||||||
|
@ -135,12 +150,6 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.21.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
@ -210,7 +219,13 @@ version = "0.4.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"android-tzdata",
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -648,11 +663,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "headers"
|
name = "headers"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
|
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"headers-core",
|
"headers-core",
|
||||||
"http",
|
"http",
|
||||||
|
@ -820,7 +835,7 @@ version = "0.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
|
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
@ -840,6 +855,30 @@ dependencies = [
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
|
@ -1048,6 +1087,15 @@ version = "0.4.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata 0.1.10",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
@ -1496,8 +1544,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
"regex-automata",
|
"regex-automata 0.4.9",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||||
|
dependencies = [
|
||||||
|
"regex-syntax 0.6.29",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1508,9 +1565,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
"regex-syntax",
|
"regex-syntax 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.6.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
|
@ -1519,12 +1582,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.18"
|
version = "0.12.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
|
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
@ -1886,8 +1949,9 @@ version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
|
@ -1959,10 +2023,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
@ -2001,9 +2066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
|
@ -2038,6 +2104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
|
"chrono",
|
||||||
"flume",
|
"flume",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
@ -2178,6 +2245,7 @@ name = "timezone-db"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"headers",
|
"headers",
|
||||||
|
@ -2186,6 +2254,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
@ -2311,12 +2380,13 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.4"
|
version = "0.6.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
|
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
@ -2398,10 +2468,14 @@ version = "0.3.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
@ -2652,6 +2726,41 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
@ -2665,7 +2774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
|
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-result",
|
"windows-result",
|
||||||
"windows-strings",
|
"windows-strings 0.3.1",
|
||||||
"windows-targets 0.53.0",
|
"windows-targets 0.53.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -2687,6 +2796,15 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
|
13
Cargo.toml
13
Cargo.toml
|
@ -5,18 +5,25 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full", "signal"] }
|
||||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "macros"] }
|
sqlx = { version = "0.8.6", features = [
|
||||||
|
"postgres",
|
||||||
|
"runtime-tokio",
|
||||||
|
"macros",
|
||||||
|
"chrono",
|
||||||
|
] }
|
||||||
redis = { version = "0.31", features = ["tokio-comp", "aio"] }
|
redis = { version = "0.31", features = ["tokio-comp", "aio"] }
|
||||||
uuid = { version = "1", features = ["v7"] }
|
uuid = { version = "1", features = ["v7"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
reqwest = { version = "0.12", features = ["json", "gzip"] }
|
reqwest = { version = "0.12", features = ["json", "gzip"] }
|
||||||
tower-http = { version = "0.6.4", features = ["cors", "fs"] }
|
tower-http = { version = "0.6.4", features = ["cors", "fs"] }
|
||||||
headers = "0.4.0"
|
headers = "0.4.0"
|
||||||
chrono-tz = "0.10.3"
|
chrono-tz = "0.10.3"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
# Stage 1: Build
|
|
||||||
FROM rustlang/rust:nightly AS builder
|
FROM rustlang/rust:nightly AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
@ -6,6 +5,7 @@ WORKDIR /app
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@ WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/timezone-db /usr/local/bin/app
|
COPY --from=builder /app/target/release/timezone-db /usr/local/bin/app
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
ENV RUST_LOG=info
|
|
||||||
|
|
||||||
CMD ["/usr/local/bin/app"]
|
CMD ["/usr/local/bin/app"]
|
||||||
|
|
73
README.md
73
README.md
|
@ -7,8 +7,10 @@ A simple Rust-powered API service for managing and retrieving user timezones.
|
||||||
- Store user timezones via `/set` endpoint (requires Discord OAuth)
|
- Store user timezones via `/set` endpoint (requires Discord OAuth)
|
||||||
- Retrieve timezones by user ID via `/get`
|
- Retrieve timezones by user ID via `/get`
|
||||||
- List all saved timezones
|
- List all saved timezones
|
||||||
- Cookie-based session handling using Redis
|
- Cookie-based session handling using Redis connection pooling
|
||||||
- Built-in CORS support
|
- Built-in CORS support
|
||||||
|
- Structured configuration with validation
|
||||||
|
- Graceful shutdown support
|
||||||
- Fully containerized with PostgreSQL and DragonflyDB
|
- Fully containerized with PostgreSQL and DragonflyDB
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
@ -21,15 +23,27 @@ A simple Rust-powered API service for managing and retrieving user timezones.
|
||||||
Create a `.env` file with the following:
|
Create a `.env` file with the following:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# Server Configuration
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
|
DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
|
||||||
REDIS_URL=redis://dragonfly:6379
|
DB_MAX_CONNECTIONS=10
|
||||||
|
DB_CONNECT_TIMEOUT=30
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL=redis://dragonfly:6379
|
||||||
|
REDIS_POOL_SIZE=5
|
||||||
|
REDIS_CONNECT_TIMEOUT=10
|
||||||
|
|
||||||
|
# Discord OAuth Configuration
|
||||||
CLIENT_ID=your_discord_client_id
|
CLIENT_ID=your_discord_client_id
|
||||||
CLIENT_SECRET=your_discord_client_secret
|
CLIENT_SECRET=your_discord_client_secret
|
||||||
REDIRECT_URI=https://your.domain/auth/discord/callback
|
REDIRECT_URI=https://your.domain/auth/discord/callback
|
||||||
|
|
||||||
|
# Logging (optional)
|
||||||
|
RUST_LOG=info,timezone_db=debug
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
@ -40,17 +54,36 @@ REDIRECT_URI=https://your.domain/auth/discord/callback
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Run Manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure PostgreSQL and Redis are running
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### `GET /get?id=<discord_user_id>`
|
### `GET /get?id=<discord_user_id>`
|
||||||
|
|
||||||
Returns stored timezone and username for the given user ID.
|
Returns stored timezone and username for the given user ID.
|
||||||
|
|
||||||
### `GET /set?timezone=<iana_timezone>`
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": "123456789",
|
||||||
|
"username": "username"
|
||||||
|
},
|
||||||
|
"timezone": "America/New_York"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Stores timezone for the authenticated user. Requires Discord OAuth session.
|
### `POST /set`
|
||||||
|
|
||||||
### `GET /delete`
|
Stores timezone for the authenticated user. Requires Discord OAuth session.
|
||||||
|
**Body:** `application/x-www-form-urlencoded` with `timezone=<iana_timezone>`
|
||||||
|
|
||||||
|
### `DELETE /delete`
|
||||||
|
|
||||||
Deletes the authenticated user's timezone entry. Requires Discord OAuth session.
|
Deletes the authenticated user's timezone entry. Requires Discord OAuth session.
|
||||||
|
|
||||||
|
@ -58,18 +91,40 @@ Deletes the authenticated user's timezone entry. Requires Discord OAuth session.
|
||||||
|
|
||||||
Returns a JSON object of all stored timezones by user ID.
|
Returns a JSON object of all stored timezones by user ID.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"123456789": {
|
||||||
|
"username": "user1",
|
||||||
|
"timezone": "America/New_York"
|
||||||
|
},
|
||||||
|
"987654321": {
|
||||||
|
"username": "user2",
|
||||||
|
"timezone": "Europe/London"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `GET /me`
|
### `GET /me`
|
||||||
|
|
||||||
Returns Discord profile info for the current session.
|
Returns Discord profile info and timezone for the current session.
|
||||||
|
|
||||||
### `GET /auth/discord`
|
### `GET /auth/discord`
|
||||||
|
|
||||||
Starts OAuth2 authentication flow.
|
Starts OAuth2 authentication flow. Supports optional `?redirect=` parameter.
|
||||||
|
|
||||||
### `GET /auth/discord/callback`
|
### `GET /auth/discord/callback`
|
||||||
|
|
||||||
Handles OAuth2 redirect and sets a session cookie.
|
Handles OAuth2 redirect and sets a session cookie.
|
||||||
|
|
||||||
## License
|
## Configuration
|
||||||
|
|
||||||
[BSD-3-Clause](LICENSE)
|
The application uses structured configuration with validation. All required environment variables must be provided, and the app will exit with helpful error messages if configuration is invalid.
|
||||||
|
|
||||||
|
### Optional Configuration Variables
|
||||||
|
|
||||||
|
- `DB_MAX_CONNECTIONS`: Maximum database connections (default: 10)
|
||||||
|
- `DB_CONNECT_TIMEOUT`: Database connection timeout in seconds (default: 30)
|
||||||
|
- `REDIS_POOL_SIZE`: Redis connection pool size (default: 5)
|
||||||
|
- `REDIS_CONNECT_TIMEOUT`: Redis connection timeout in seconds (default: 10)
|
||||||
|
- `RUST_LOG`: Logging level configuration
|
|
@ -15,6 +15,12 @@ services:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- timezoneDB-network
|
- timezoneDB-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://${HOST:-localhost}:${PORT:-3000}/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
@ -23,6 +29,7 @@ services:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
|
PGUSER: postgres
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres-data:/var/lib/postgresql/data
|
- ./postgres-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
|
@ -45,4 +52,4 @@ services:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
timezoneDB-network:
|
timezoneDB-network:
|
||||||
driver: bridge
|
driver: bridge
|
9
migrations/001_initial.sql
Normal file
9
migrations/001_initial.sql
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS timezones (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
timezone TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timezones_updated_at ON timezones(updated_at);
|
5
migrations/002_created_at-updated_at.sql
Normal file
5
migrations/002_created_at-updated_at.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
ALTER TABLE timezones
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
|
||||||
|
ALTER TABLE timezones
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
|
|
@ -164,6 +164,25 @@ button.danger:hover {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.open-source-logo {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
36
public/forgejo_logo.svg
Normal file
36
public/forgejo_logo.svg
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<svg viewBox="0 0 212 212" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
|
||||||
|
<dc:title>Forgejo logo</dc:title>
|
||||||
|
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
|
||||||
|
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<style type="text/css">
|
||||||
|
circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 15;
|
||||||
|
}
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 25;
|
||||||
|
}
|
||||||
|
.orange {
|
||||||
|
stroke:#ff6600;
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
stroke:#d40000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g transform="translate(6,6)">
|
||||||
|
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange"/>
|
||||||
|
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red"/>
|
||||||
|
<circle cx="142" cy="20" r="18" class="orange"/>
|
||||||
|
<circle cx="142" cy="88" r="18" class="red"/>
|
||||||
|
<circle cx="58" cy="180" r="18" class="red"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
|
@ -14,6 +14,14 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<header>
|
||||||
|
<a href="https://git.creations.works/creations/timezoneDB" target="_blank" rel="noopener noreferrer"
|
||||||
|
title="View source code on Forgejo">
|
||||||
|
<img class="open-source-logo" src="/public/forgejo_logo.svg" alt="Forgejo open-source logo"
|
||||||
|
style="opacity: 0.5" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="login-section" class="hidden">
|
<section id="login-section" class="hidden">
|
||||||
<h1>Timezone DB</h1>
|
<h1>Timezone DB</h1>
|
||||||
|
|
|
@ -7,18 +7,19 @@ const setBtn = document.getElementById("set-timezone");
|
||||||
const statusMsg = document.getElementById("status-msg");
|
const statusMsg = document.getElementById("status-msg");
|
||||||
|
|
||||||
const timezones = Intl.supportedValuesOf("timeZone");
|
const timezones = Intl.supportedValuesOf("timeZone");
|
||||||
timezones.forEach(tz => {
|
|
||||||
|
for (const tz of timezones) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = tz;
|
opt.value = tz;
|
||||||
opt.textContent = tz;
|
opt.textContent = tz;
|
||||||
timezoneSelect.appendChild(opt);
|
timezoneSelect.appendChild(opt);
|
||||||
});
|
}
|
||||||
|
|
||||||
const ts = new TomSelect("#timezone-select", {
|
const ts = new TomSelect("#timezone-select", {
|
||||||
create: false,
|
create: false,
|
||||||
sorted: true,
|
sorted: true,
|
||||||
searchField: ["text"],
|
searchField: ["text"],
|
||||||
maxOptions: 1000
|
maxOptions: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
|
@ -52,7 +53,11 @@ async function fetchUserInfo() {
|
||||||
|
|
||||||
deleteBtn.addEventListener("click", async () => {
|
deleteBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/delete", { credentials: "include" });
|
const res = await fetch("/delete", {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
ts.clear();
|
ts.clear();
|
||||||
|
@ -62,7 +67,6 @@ async function fetchUserInfo() {
|
||||||
statusMsg.textContent = "Failed to delete timezone.";
|
statusMsg.textContent = "Failed to delete timezone.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
loginSection.classList.remove("hidden");
|
loginSection.classList.remove("hidden");
|
||||||
timezoneSection.classList.add("hidden");
|
timezoneSection.classList.add("hidden");
|
||||||
|
@ -73,14 +77,30 @@ setBtn.addEventListener("click", async () => {
|
||||||
const timezone = ts.getValue();
|
const timezone = ts.getValue();
|
||||||
if (!timezone) return;
|
if (!timezone) return;
|
||||||
|
|
||||||
|
setBtn.disabled = true;
|
||||||
|
setBtn.textContent = "Saving...";
|
||||||
|
statusMsg.textContent = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/set?timezone=${encodeURIComponent(timezone)}`, {
|
const res = await fetch("/set", {
|
||||||
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: `timezone=${encodeURIComponent(timezone)}`,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || "Failed to update timezone");
|
||||||
|
}
|
||||||
|
|
||||||
statusMsg.textContent = "Timezone updated!";
|
statusMsg.textContent = "Timezone updated!";
|
||||||
} catch {
|
document.getElementById("delete-timezone").classList.remove("hidden");
|
||||||
statusMsg.textContent = "Failed to update timezone.";
|
} catch (error) {
|
||||||
|
statusMsg.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
setBtn.disabled = false;
|
||||||
|
setBtn.textContent = "Save";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
9
rustfmt.toml
Normal file
9
rustfmt.toml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
max_width = 100
|
||||||
|
hard_tabs = false
|
||||||
|
tab_spaces = 4
|
||||||
|
newline_style = "Unix"
|
||||||
|
use_small_heuristics = "Default"
|
||||||
|
reorder_imports = true
|
||||||
|
reorder_modules = true
|
||||||
|
remove_nested_parens = true
|
||||||
|
edition = "2021"
|
197
src/config.rs
Normal file
197
src/config.rs
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
use std::env;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
pub redis: RedisConfig,
|
||||||
|
pub discord: DiscordConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub bind_address: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub max_connections: u32,
|
||||||
|
pub connect_timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RedisConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub pool_size: u32,
|
||||||
|
pub connect_timeout_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiscordConfig {
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Missing required environment variable: {0}")]
|
||||||
|
MissingEnvVar(String),
|
||||||
|
#[error("Invalid value for {var}: {value} - {reason}")]
|
||||||
|
InvalidValue {
|
||||||
|
var: String,
|
||||||
|
value: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
#[error("Parse error for {var}: {source}")]
|
||||||
|
ParseError {
|
||||||
|
var: String,
|
||||||
|
#[source]
|
||||||
|
source: Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let server = ServerConfig::from_env()?;
|
||||||
|
let database = DatabaseConfig::from_env()?;
|
||||||
|
let redis = RedisConfig::from_env()?;
|
||||||
|
let discord = DiscordConfig::from_env()?;
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
server,
|
||||||
|
database,
|
||||||
|
redis,
|
||||||
|
discord,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if !self.discord.redirect_uri.starts_with("http") {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
var: "REDIRECT_URI".to_string(),
|
||||||
|
value: self.discord.redirect_uri.clone(),
|
||||||
|
reason: "Must start with http:// or https://".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.database.url.starts_with("postgres://")
|
||||||
|
&& !self.database.url.starts_with("postgresql://")
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
var: "DATABASE_URL".to_string(),
|
||||||
|
value: "***hidden***".to_string(),
|
||||||
|
reason: "Must be a valid PostgreSQL connection string".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.redis.url.starts_with("redis://") && !self.redis.url.starts_with("rediss://") {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
var: "REDIS_URL".to_string(),
|
||||||
|
value: "***hidden***".to_string(),
|
||||||
|
reason: "Must be a valid Redis connection string".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let host = get_env_or("HOST", "0.0.0.0")?
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "HOST".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let port =
|
||||||
|
get_env_or("PORT", "3000")?
|
||||||
|
.parse::<u16>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "PORT".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let bind_address = SocketAddr::new(host, port);
|
||||||
|
|
||||||
|
Ok(ServerConfig { bind_address })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let url = get_env_required("DATABASE_URL")?;
|
||||||
|
|
||||||
|
let max_connections = get_env_or("DB_MAX_CONNECTIONS", "10")?
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "DB_MAX_CONNECTIONS".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connect_timeout_seconds = get_env_or("DB_CONNECT_TIMEOUT", "30")?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "DB_CONNECT_TIMEOUT".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(DatabaseConfig {
|
||||||
|
url,
|
||||||
|
max_connections,
|
||||||
|
connect_timeout_seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let url = get_env_required("REDIS_URL")?;
|
||||||
|
|
||||||
|
let pool_size = get_env_or("REDIS_POOL_SIZE", "5")?
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "REDIS_POOL_SIZE".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connect_timeout_seconds = get_env_or("REDIS_CONNECT_TIMEOUT", "10")?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|e| ConfigError::ParseError {
|
||||||
|
var: "REDIS_CONNECT_TIMEOUT".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(RedisConfig {
|
||||||
|
url,
|
||||||
|
pool_size,
|
||||||
|
connect_timeout_seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscordConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let client_id = get_env_required("CLIENT_ID")?;
|
||||||
|
let client_secret = get_env_required("CLIENT_SECRET")?;
|
||||||
|
let redirect_uri = get_env_required("REDIRECT_URI")?;
|
||||||
|
|
||||||
|
Ok(DiscordConfig {
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_env_required(key: &str) -> Result<String, ConfigError> {
|
||||||
|
env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_env_or(key: &str, default: &str) -> Result<String, ConfigError> {
|
||||||
|
Ok(env::var(key).unwrap_or_else(|_| default.to_string()))
|
||||||
|
}
|
|
@ -1,11 +1,23 @@
|
||||||
pub mod postgres;
|
pub mod postgres;
|
||||||
pub mod redis_helper;
|
pub mod redis_helper;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
pub use redis_helper::RedisPool;
|
||||||
|
|
||||||
pub type Db = sqlx::PgPool;
|
pub type Db = sqlx::PgPool;
|
||||||
pub type Redis = redis::aio::MultiplexedConnection;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Db,
|
pub db: Db,
|
||||||
pub redis: Redis,
|
pub redis: RedisPool,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AppState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AppState")
|
||||||
|
.field("redis", &self.redis)
|
||||||
|
.field("config", &self.config)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +1,122 @@
|
||||||
|
use crate::config::DatabaseConfig;
|
||||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
use std::env;
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
pub async fn connect() -> PgPool {
|
use std::time::Duration;
|
||||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is required");
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(config.max_connections)
|
||||||
.connect(&db_url)
|
.acquire_timeout(Duration::from_secs(config.connect_timeout_seconds))
|
||||||
.await
|
.idle_timeout(Some(Duration::from_secs(600)))
|
||||||
.expect("Failed to connect to Postgres");
|
.max_lifetime(Some(Duration::from_secs(1800)))
|
||||||
|
.connect(&config.url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
create_migrations_table(&pool).await?;
|
||||||
|
|
||||||
|
run_migrations(&pool).await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_migrations_table(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS timezones (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
user_id TEXT PRIMARY KEY,
|
version TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
timezone TEXT NOT NULL
|
)
|
||||||
);
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(pool)
|
||||||
.await
|
.await?;
|
||||||
.expect("Failed to create timezones table");
|
|
||||||
|
|
||||||
pool
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
|
let migrations_dir = Path::new("migrations");
|
||||||
|
|
||||||
|
if !migrations_dir.exists() {
|
||||||
|
warn!("Migrations directory not found, skipping migrations");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut migration_files = Vec::new();
|
||||||
|
|
||||||
|
match fs::read_dir(migrations_dir) {
|
||||||
|
Ok(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
if let Ok(entry) = entry {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("sql") {
|
||||||
|
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||||
|
migration_files.push(file_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read migrations directory: {}", e);
|
||||||
|
return Err(sqlx::Error::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to read migrations directory: {}", e),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migration_files.sort();
|
||||||
|
|
||||||
|
for migration_file in migration_files {
|
||||||
|
let applied = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)",
|
||||||
|
)
|
||||||
|
.bind(&migration_file)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if applied {
|
||||||
|
info!("Migration {} already applied, skipping", migration_file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let migration_path = migrations_dir.join(&migration_file);
|
||||||
|
let migration_sql = match fs::read_to_string(&migration_path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read migration file {}: {}", migration_file, e);
|
||||||
|
return Err(sqlx::Error::Io(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Running migration: {}", migration_file);
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let statements: Vec<&str> = migration_sql
|
||||||
|
.split(';')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for statement in statements {
|
||||||
|
if let Err(e) = sqlx::query(statement).execute(&mut *tx).await {
|
||||||
|
error!("Failed to execute migration {}: {}", migration_file, e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO schema_migrations (version) VALUES ($1)")
|
||||||
|
.bind(&migration_file)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
info!("Successfully applied migration: {}", migration_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,137 @@
|
||||||
use redis::aio::MultiplexedConnection;
|
use crate::config::RedisConfig;
|
||||||
use redis::Client;
|
use redis::{aio::MultiplexedConnection, Client, RedisError};
|
||||||
use std::env;
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub async fn connect() -> MultiplexedConnection {
|
pub type RedisConnection = MultiplexedConnection;
|
||||||
let url = env::var("REDIS_URL").expect("REDIS_URL is required");
|
|
||||||
let client = Client::open(url).expect("Failed to create Redis client");
|
#[derive(Clone)]
|
||||||
client
|
pub struct RedisPool {
|
||||||
.get_multiplexed_tokio_connection()
|
connections: Arc<Mutex<VecDeque<RedisConnection>>>,
|
||||||
.await
|
client: Client,
|
||||||
.expect("Failed to connect to Redis")
|
config: RedisConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RedisPool {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("RedisPool")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.field("pool_size", &self.config.pool_size)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisPool {
|
||||||
|
pub async fn new(config: RedisConfig) -> Result<Self, RedisError> {
|
||||||
|
let client = Client::open(config.url.clone())?;
|
||||||
|
let connections = Arc::new(Mutex::new(VecDeque::new()));
|
||||||
|
|
||||||
|
let pool = RedisPool {
|
||||||
|
connections,
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
|
||||||
|
pool.initialize_pool().await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize_pool(&self) -> Result<(), RedisError> {
|
||||||
|
let mut connections = self.connections.lock().await;
|
||||||
|
|
||||||
|
for _ in 0..self.config.pool_size {
|
||||||
|
let conn = self.create_connection().await?;
|
||||||
|
connections.push_back(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_connection(&self) -> Result<RedisConnection, RedisError> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(self.config.connect_timeout_seconds),
|
||||||
|
self.client.get_multiplexed_tokio_connection(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| RedisError::from((redis::ErrorKind::IoError, "Connection timeout")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection(&self) -> Result<PooledConnection, RedisError> {
|
||||||
|
let mut connections = self.connections.lock().await;
|
||||||
|
|
||||||
|
let conn = if let Some(conn) = connections.pop_front() {
|
||||||
|
conn
|
||||||
|
} else {
|
||||||
|
drop(connections);
|
||||||
|
self.create_connection().await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(PooledConnection {
|
||||||
|
connection: Some(conn),
|
||||||
|
pool: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn return_connection(&self, conn: RedisConnection) {
|
||||||
|
let mut connections = self.connections.lock().await;
|
||||||
|
|
||||||
|
if connections.len() < self.config.pool_size as usize {
|
||||||
|
connections.push_back(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PooledConnection {
|
||||||
|
connection: Option<RedisConnection>,
|
||||||
|
pool: RedisPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PooledConnection {
|
||||||
|
pub fn as_mut(&mut self) -> &mut RedisConnection {
|
||||||
|
self.connection
|
||||||
|
.as_mut()
|
||||||
|
.expect("Connection already returned to pool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl redis::aio::ConnectionLike for PooledConnection {
|
||||||
|
fn req_packed_command<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
cmd: &'a redis::Cmd,
|
||||||
|
) -> redis::RedisFuture<'a, redis::Value> {
|
||||||
|
self.as_mut().req_packed_command(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req_packed_commands<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
cmd: &'a redis::Pipeline,
|
||||||
|
offset: usize,
|
||||||
|
count: usize,
|
||||||
|
) -> redis::RedisFuture<'a, Vec<redis::Value>> {
|
||||||
|
self.as_mut().req_packed_commands(cmd, offset, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_db(&self) -> i64 {
|
||||||
|
self.connection
|
||||||
|
.as_ref()
|
||||||
|
.expect("Connection already returned to pool")
|
||||||
|
.get_db()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PooledConnection {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(conn) = self.connection.take() {
|
||||||
|
let pool = self.pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
pool.return_connection(conn).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(config: &RedisConfig) -> Result<RedisPool, RedisError> {
|
||||||
|
RedisPool::new(config.clone()).await
|
||||||
}
|
}
|
||||||
|
|
98
src/main.rs
98
src/main.rs
|
@ -1,48 +1,104 @@
|
||||||
use axum::{serve, Router};
|
use axum::{serve, Router};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use tracing_subscriber;
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
use db::{postgres, redis_helper, AppState};
|
use db::{postgres, redis_helper, AppState};
|
||||||
use middleware::cors::DynamicCors;
|
use middleware::cors::DynamicCors;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
let db = postgres::connect().await;
|
tracing_subscriber::registry()
|
||||||
let redis = redis_helper::connect().await;
|
.with(
|
||||||
let state = AppState { db, redis };
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = match Config::from_env() {
|
||||||
|
Ok(config) => {
|
||||||
|
if let Err(e) = config.validate() {
|
||||||
|
error!("Configuration validation failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to load configuration: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Starting timezone-db server");
|
||||||
|
info!("Server will bind to: {}", config.server.bind_address);
|
||||||
|
|
||||||
|
let db = match postgres::connect(&config.database).await {
|
||||||
|
Ok(pool) => {
|
||||||
|
info!("Successfully connected to PostgreSQL");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to connect to PostgreSQL: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let redis = match redis_helper::connect(&config.redis).await {
|
||||||
|
Ok(pool) => {
|
||||||
|
info!("Successfully connected to Redis");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to connect to Redis: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
redis,
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(routes::all())
|
.merge(routes::all())
|
||||||
.with_state(state.clone())
|
.with_state(state)
|
||||||
.layer(DynamicCors);
|
.layer(DynamicCors);
|
||||||
|
|
||||||
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
let listener = match TcpListener::bind(config.server.bind_address).await {
|
||||||
let port: u16 = std::env::var("PORT")
|
Ok(listener) => listener,
|
||||||
.unwrap_or_else(|_| "3000".to_string())
|
Err(e) => {
|
||||||
.parse()
|
error!("Failed to bind to {}: {}", config.server.bind_address, e);
|
||||||
.expect("PORT must be a number");
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let addr = format!("{}:{}", host, port)
|
info!("Server listening on http://{}", config.server.bind_address);
|
||||||
.parse::<SocketAddr>()
|
|
||||||
.expect("Invalid HOST or PORT");
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(addr)
|
let shutdown_signal = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("Failed to install CTRL+C signal handler");
|
||||||
|
warn!("Shutdown signal received");
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to bind address");
|
{
|
||||||
|
|
||||||
info!("Listening on http://{}", addr);
|
|
||||||
if let Err(err) = serve(listener, app).await {
|
|
||||||
error!("Server error: {}", err);
|
error!("Server error: {}", err);
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("Server has shut down gracefully");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::db::AppState;
|
use crate::db::AppState;
|
||||||
|
|
||||||
use crate::types::JsonMessage;
|
use crate::types::JsonMessage;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
|
@ -11,7 +10,8 @@ use headers::{Cookie, HeaderMapExt};
|
||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::{collections::HashMap, env};
|
use std::collections::HashMap;
|
||||||
|
use tracing::{error, info, instrument};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -20,7 +20,7 @@ pub struct CallbackQuery {
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
pub struct DiscordUser {
|
pub struct DiscordUser {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
@ -34,6 +34,7 @@ pub struct AuthResponse {
|
||||||
session: String,
|
session: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state), fields(user_id))]
|
||||||
pub async fn get_user_from_session(
|
pub async fn get_user_from_session(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
@ -56,9 +57,20 @@ pub async fn get_user_from_session(
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut redis = state.redis.clone();
|
let mut redis_conn = state.redis.get_connection().await.map_err(|e| {
|
||||||
|
error!("Failed to get Redis connection: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(JsonMessage {
|
||||||
|
message: "Database connection error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let key = format!("session:{}", session_id);
|
let key = format!("session:{}", session_id);
|
||||||
let Ok(json) = redis.get::<_, String>(&key).await else {
|
let json: redis::RedisResult<String> = redis_conn.as_mut().get(&key).await;
|
||||||
|
|
||||||
|
let Ok(json) = json else {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -76,32 +88,39 @@ pub async fn get_user_from_session(
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tracing::Span::current().record("user_id", &user.id);
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_oauth(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
|
#[instrument(skip(state))]
|
||||||
let client_id = env::var("CLIENT_ID").unwrap_or_default();
|
pub async fn start_oauth(
|
||||||
let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let client_id = &state.config.discord.client_id;
|
||||||
|
let redirect_uri = &state.config.discord.redirect_uri;
|
||||||
|
|
||||||
let mut url = format!(
|
let mut url = format!(
|
||||||
"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
|
"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
|
||||||
client_id, redirect_uri
|
client_id, redirect_uri
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(redirect) = params.get("redirect") {
|
if let Some(redirect) = params.get("redirect") {
|
||||||
url.push_str(&format!("&state={}", urlencoding::encode(redirect)));
|
url.push_str(&format!("&state={}", urlencoding::encode(redirect)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("Starting OAuth flow");
|
||||||
(StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response()
|
(StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state, query), fields(user_id))]
|
||||||
pub async fn handle_callback(
|
pub async fn handle_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<CallbackQuery>,
|
Query(query): Query<CallbackQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let client_id = env::var("CLIENT_ID").unwrap();
|
let client_id = &state.config.discord.client_id;
|
||||||
let client_secret = env::var("CLIENT_SECRET").unwrap();
|
let client_secret = &state.config.discord.client_secret;
|
||||||
let redirect_uri = env::var("REDIRECT_URI").unwrap();
|
let redirect_uri = &state.config.discord.redirect_uri;
|
||||||
|
|
||||||
let form = [
|
let form = [
|
||||||
("client_id", client_id.as_str()),
|
("client_id", client_id.as_str()),
|
||||||
|
@ -118,6 +137,7 @@ pub async fn handle_callback(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let Ok(res) = token_res else {
|
let Ok(res) = token_res else {
|
||||||
|
error!("Failed to exchange OAuth code for token");
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -128,6 +148,7 @@ pub async fn handle_callback(
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(token_json) = res.json::<serde_json::Value>().await else {
|
let Ok(token_json) = res.json::<serde_json::Value>().await else {
|
||||||
|
error!("Invalid token response from Discord");
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -138,6 +159,7 @@ pub async fn handle_callback(
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(access_token) = token_json["access_token"].as_str() else {
|
let Some(access_token) = token_json["access_token"].as_str() else {
|
||||||
|
error!("Access token not found in Discord response");
|
||||||
return (
|
return (
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -154,6 +176,7 @@ pub async fn handle_callback(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let Ok(user_res) = user_res else {
|
let Ok(user_res) = user_res else {
|
||||||
|
error!("Failed to fetch user info from Discord");
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -164,6 +187,7 @@ pub async fn handle_callback(
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(user) = user_res.json::<DiscordUser>().await else {
|
let Ok(user) = user_res.json::<DiscordUser>().await else {
|
||||||
|
error!("Failed to parse user info from Discord");
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(JsonMessage {
|
Json(JsonMessage {
|
||||||
|
@ -173,15 +197,42 @@ pub async fn handle_callback(
|
||||||
.into_response();
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tracing::Span::current().record("user_id", &user.id);
|
||||||
|
|
||||||
let session_id = Uuid::now_v7().to_string();
|
let session_id = Uuid::now_v7().to_string();
|
||||||
let mut redis = state.redis.clone();
|
|
||||||
let _ = redis
|
let mut redis_conn = match state.redis.get_connection().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get Redis connection: {}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(JsonMessage {
|
||||||
|
message: "Database connection error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = redis_conn
|
||||||
|
.as_mut()
|
||||||
.set_ex::<_, _, ()>(
|
.set_ex::<_, _, ()>(
|
||||||
format!("session:{}", session_id),
|
format!("session:{}", session_id),
|
||||||
serde_json::to_string(&user).unwrap(),
|
serde_json::to_string(&user).unwrap(),
|
||||||
3600,
|
3600,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to store session in Redis: {}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(JsonMessage {
|
||||||
|
message: "Failed to create session".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let redirect_target = match &query.state {
|
let redirect_target = match &query.state {
|
||||||
Some(s) => urlencoding::decode(s)
|
Some(s) => urlencoding::decode(s)
|
||||||
|
@ -205,9 +256,11 @@ pub async fn handle_callback(
|
||||||
redirect_target.parse().unwrap(),
|
redirect_target.parse().unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
info!(user_id = %user.id, username = %user.username, "User logged in successfully");
|
||||||
(StatusCode::FOUND, headers).into_response()
|
(StatusCode::FOUND, headers).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state))]
|
||||||
pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
||||||
match get_user_from_session(&headers, &state).await {
|
match get_user_from_session(&headers, &state).await {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
|
@ -236,13 +289,16 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoR
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Err(_) => (
|
Err(e) => {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
error!("Database error while fetching timezone: {}", e);
|
||||||
Json(JsonMessage {
|
(
|
||||||
message: "Failed to fetch timezone".into(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}),
|
Json(JsonMessage {
|
||||||
)
|
message: "Failed to fetch timezone".into(),
|
||||||
.into_response(),
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => err.into_response(),
|
Err(err) => err.into_response(),
|
||||||
|
|
31
src/routes/health.rs
Normal file
31
src/routes/health.rs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
use axum::{extract::State, response::IntoResponse, Json};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::db::AppState;
|
||||||
|
|
||||||
|
pub async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db_healthy = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||||
|
|
||||||
|
let redis_healthy = state.redis.get_connection().await.is_ok();
|
||||||
|
|
||||||
|
let status = if db_healthy && redis_healthy {
|
||||||
|
"healthy"
|
||||||
|
} else {
|
||||||
|
"unhealthy"
|
||||||
|
};
|
||||||
|
let status_code = if status == "healthy" {
|
||||||
|
StatusCode::OK
|
||||||
|
} else {
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status_code,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"database": db_healthy,
|
||||||
|
"redis": redis_healthy,
|
||||||
|
"timestamp": chrono::Utc::now()
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,13 +2,14 @@ use crate::db::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderValue, StatusCode},
|
http::{HeaderValue, StatusCode},
|
||||||
response::{Html, Response},
|
response::{Html, Response},
|
||||||
routing::{get, options},
|
routing::{delete, get, options, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
pub mod auth;
|
mod auth;
|
||||||
|
mod health;
|
||||||
mod timezone;
|
mod timezone;
|
||||||
|
|
||||||
async fn preflight_handler() -> Response {
|
async fn preflight_handler() -> Response {
|
||||||
|
@ -18,7 +19,7 @@ async fn preflight_handler() -> Response {
|
||||||
headers.insert("access-control-allow-origin", HeaderValue::from_static("*"));
|
headers.insert("access-control-allow-origin", HeaderValue::from_static("*"));
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"access-control-allow-methods",
|
"access-control-allow-methods",
|
||||||
HeaderValue::from_static("GET, POST, OPTIONS"),
|
HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
|
||||||
);
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"access-control-allow-headers",
|
"access-control-allow-headers",
|
||||||
|
@ -46,13 +47,14 @@ pub fn all() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index_page))
|
.route("/", get(index_page))
|
||||||
.route("/get", get(timezone::get_timezone))
|
.route("/get", get(timezone::get_timezone))
|
||||||
.route("/set", get(timezone::set_timezone))
|
.route("/set", post(timezone::set_timezone))
|
||||||
.route("/set", options(preflight_handler))
|
.route("/set", options(preflight_handler))
|
||||||
.route("/delete", get(timezone::delete_timezone))
|
.route("/delete", delete(timezone::delete_timezone))
|
||||||
.route("/list", get(timezone::list_timezones))
|
.route("/list", get(timezone::list_timezones))
|
||||||
.route("/auth/discord", get(auth::start_oauth))
|
.route("/auth/discord", get(auth::start_oauth))
|
||||||
.route("/auth/discord/callback", get(auth::handle_callback))
|
.route("/auth/discord/callback", get(auth::handle_callback))
|
||||||
.route("/me", get(auth::me))
|
.route("/me", get(auth::me))
|
||||||
|
.route("/health", get(health::health_check))
|
||||||
.nest_service("/public", ServeDir::new("public"))
|
.nest_service("/public", ServeDir::new("public"))
|
||||||
.fallback(get(index_page))
|
.fallback(get(index_page))
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Form, Json,
|
||||||
};
|
};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use headers::{Cookie, HeaderMapExt};
|
use headers::{Cookie, HeaderMapExt};
|
||||||
|
@ -13,6 +13,7 @@ use redis::AsyncCommands;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TimezoneResponse {
|
pub struct TimezoneResponse {
|
||||||
|
@ -132,9 +133,22 @@ pub async fn delete_timezone(
|
||||||
.into_response();
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut redis = state.redis.clone();
|
let mut redis_conn = match state.redis.get_connection().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get Redis connection: {}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(JsonMessage {
|
||||||
|
message: "Database connection error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let key = format!("session:{}", session_id);
|
let key = format!("session:{}", session_id);
|
||||||
let json: redis::RedisResult<String> = redis.get(&key).await;
|
let json: redis::RedisResult<String> = redis_conn.get(&key).await;
|
||||||
|
|
||||||
let Ok(json) = json else {
|
let Ok(json) = json else {
|
||||||
return (
|
return (
|
||||||
|
@ -182,7 +196,7 @@ pub async fn delete_timezone(
|
||||||
pub async fn set_timezone(
|
pub async fn set_timezone(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(query): Query<SetQuery>,
|
Form(query): Form<SetQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
|
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
|
||||||
return (
|
return (
|
||||||
|
@ -204,9 +218,22 @@ pub async fn set_timezone(
|
||||||
.into_response();
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut redis = state.redis.clone();
|
let mut redis_conn = match state.redis.get_connection().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to get Redis connection: {}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(JsonMessage {
|
||||||
|
message: "Database connection error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let key = format!("session:{}", session_id);
|
let key = format!("session:{}", session_id);
|
||||||
let json: redis::RedisResult<String> = redis.get(&key).await;
|
let json: redis::RedisResult<String> = redis_conn.get(&key).await;
|
||||||
|
|
||||||
let Ok(json) = json else {
|
let Ok(json) = json else {
|
||||||
return (
|
return (
|
||||||
|
@ -251,11 +278,11 @@ pub async fn set_timezone(
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO timezones (user_id, username, timezone)
|
INSERT INTO timezones (user_id, username, timezone)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (user_id) DO UPDATE
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
SET username = EXCLUDED.username, timezone = EXCLUDED.timezone
|
SET username = EXCLUDED.username, timezone = EXCLUDED.timezone
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&user.id)
|
.bind(&user.id)
|
||||||
.bind(&user.username)
|
.bind(&user.username)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue