Compare commits

..

1 commit
dev ... main

Author SHA1 Message Date
cbe4a4e653 Add LICENSE 2025-06-03 02:27:10 +02:00
25 changed files with 167 additions and 1059 deletions

View file

@ -1,8 +1,2 @@
target/ postgres-data
postgres-data/ dragonfly-data
dragonfly-data/
.env
.git/
.gitignore
README.md
*.log

View file

@ -1,21 +0,0 @@
# 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

162
Cargo.lock generated
View file

@ -32,21 +32,6 @@ 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"
@ -150,6 +135,12 @@ 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"
@ -219,13 +210,7 @@ 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]]
@ -663,11 +648,11 @@ dependencies = [
[[package]] [[package]]
name = "headers" name = "headers"
version = "0.4.1" 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 = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
dependencies = [ dependencies = [
"base64", "base64 0.21.7",
"bytes", "bytes",
"headers-core", "headers-core",
"http", "http",
@ -835,7 +820,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", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -855,30 +840,6 @@ 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"
@ -1087,15 +1048,6 @@ 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"
@ -1544,17 +1496,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.9", "regex-automata",
"regex-syntax 0.8.5", "regex-syntax",
]
[[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]]
@ -1565,15 +1508,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-syntax 0.8.5", "regex-syntax",
] ]
[[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"
@ -1582,12 +1519,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.19" version = "0.12.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"base64", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
@ -1949,9 +1886,8 @@ 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", "base64 0.22.1",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@ -2023,11 +1959,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest",
"dotenvy", "dotenvy",
@ -2066,10 +2001,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@ -2104,7 +2038,6 @@ 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",
@ -2245,7 +2178,6 @@ name = "timezone-db"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"chrono",
"chrono-tz", "chrono-tz",
"dotenvy", "dotenvy",
"headers", "headers",
@ -2254,7 +2186,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
@ -2380,13 +2311,12 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.6" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@ -2468,14 +2398,10 @@ 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",
] ]
@ -2726,41 +2652,6 @@ 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"
@ -2774,7 +2665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [ dependencies = [
"windows-result", "windows-result",
"windows-strings 0.3.1", "windows-strings",
"windows-targets 0.53.0", "windows-targets 0.53.0",
] ]
@ -2796,15 +2687,6 @@ 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"

View file

@ -5,25 +5,18 @@ edition = "2021"
[dependencies] [dependencies]
axum = "0.8.4" axum = "0.8.4"
tokio = { version = "1", features = ["full", "signal"] } tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8.6", features = [ sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "macros"] }
"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 = { version = "0.3", features = ["env-filter"] } tracing-subscriber = "0.3"
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"

View file

@ -1,3 +1,4 @@
# Stage 1: Build
FROM rustlang/rust:nightly AS builder FROM rustlang/rust:nightly AS builder
WORKDIR /app WORKDIR /app
@ -5,7 +6,6 @@ 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,6 +17,7 @@ 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"]

28
LICENSE Normal file
View file

@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025, creations.works
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -7,10 +7,8 @@ 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 connection pooling - Cookie-based session handling using Redis
- 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
@ -23,27 +21,15 @@ 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
DB_MAX_CONNECTIONS=10
DB_CONNECT_TIMEOUT=30
# Redis Configuration
REDIS_URL=redis://dragonfly:6379 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
@ -54,36 +40,17 @@ RUST_LOG=info,timezone_db=debug
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.
**Response:** ### `GET /set?timezone=<iana_timezone>`
```json
{
"user": {
"id": "123456789",
"username": "username"
},
"timezone": "America/New_York"
}
```
### `POST /set`
Stores timezone for the authenticated user. Requires Discord OAuth session. Stores timezone for the authenticated user. Requires Discord OAuth session.
**Body:** `application/x-www-form-urlencoded` with `timezone=<iana_timezone>`
### `DELETE /delete` ### `GET /delete`
Deletes the authenticated user's timezone entry. Requires Discord OAuth session. Deletes the authenticated user's timezone entry. Requires Discord OAuth session.
@ -91,40 +58,18 @@ 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 and timezone for the current session. Returns Discord profile info for the current session.
### `GET /auth/discord` ### `GET /auth/discord`
Starts OAuth2 authentication flow. Supports optional `?redirect=` parameter. Starts OAuth2 authentication flow.
### `GET /auth/discord/callback` ### `GET /auth/discord/callback`
Handles OAuth2 redirect and sets a session cookie. Handles OAuth2 redirect and sets a session cookie.
## Configuration ## 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. [BSD-3-Clause](LICENSE)
### 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

View file

@ -15,12 +15,6 @@ 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
@ -29,7 +23,6 @@ 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:

View file

@ -1,9 +0,0 @@
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);

View file

@ -1,5 +0,0 @@
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();

View file

@ -164,25 +164,6 @@ 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;

View file

@ -1,36 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -14,14 +14,6 @@
</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>

View file

@ -7,19 +7,18 @@ 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() {
@ -53,11 +52,7 @@ async function fetchUserInfo() {
deleteBtn.addEventListener("click", async () => { deleteBtn.addEventListener("click", async () => {
try { try {
const res = await fetch("/delete", { const res = await fetch("/delete", { credentials: "include" });
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
ts.clear(); ts.clear();
@ -67,6 +62,7 @@ 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");
@ -77,30 +73,14 @@ 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", { const res = await fetch(`/set?timezone=${encodeURIComponent(timezone)}`, {
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!";
document.getElementById("delete-timezone").classList.remove("hidden"); } catch {
} catch (error) { statusMsg.textContent = "Failed to update timezone.";
statusMsg.textContent = error.message;
} finally {
setBtn.disabled = false;
setBtn.textContent = "Save";
} }
}); });

View file

@ -1,9 +0,0 @@
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"

View file

@ -1,197 +0,0 @@
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()))
}

View file

@ -1,23 +1,11 @@
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: RedisPool, pub redis: Redis,
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()
}
} }

View file

@ -1,122 +1,27 @@
use crate::config::DatabaseConfig;
use sqlx::{postgres::PgPoolOptions, PgPool}; use sqlx::{postgres::PgPoolOptions, PgPool};
use std::fs; use std::env;
use std::path::Path;
use std::time::Duration; pub async fn connect() -> PgPool {
use tracing::{error, info, warn}; let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is required");
pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(config.max_connections) .max_connections(5)
.acquire_timeout(Duration::from_secs(config.connect_timeout_seconds)) .connect(&db_url)
.idle_timeout(Some(Duration::from_secs(600))) .await
.max_lifetime(Some(Duration::from_secs(1800))) .expect("Failed to connect to Postgres");
.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 schema_migrations ( CREATE TABLE IF NOT EXISTS timezones (
version TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW() username TEXT NOT NULL,
) timezone TEXT NOT NULL
);
"#, "#,
) )
.execute(pool) .execute(&pool)
.await?; .await
.expect("Failed to create timezones table");
Ok(()) pool
}
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(())
} }

View file

@ -1,137 +1,12 @@
use crate::config::RedisConfig; use redis::aio::MultiplexedConnection;
use redis::{aio::MultiplexedConnection, Client, RedisError}; use redis::Client;
use std::collections::VecDeque; use std::env;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
pub type RedisConnection = MultiplexedConnection; pub async fn connect() -> MultiplexedConnection {
let url = env::var("REDIS_URL").expect("REDIS_URL is required");
#[derive(Clone)] let client = Client::open(url).expect("Failed to create Redis client");
pub struct RedisPool { client
connections: Arc<Mutex<VecDeque<RedisConnection>>>, .get_multiplexed_tokio_connection()
client: Client,
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 .await
.map_err(|_| RedisError::from((redis::ErrorKind::IoError, "Connection timeout")))? .expect("Failed to connect to Redis")
}
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
} }

View file

@ -1,104 +1,48 @@
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, warn}; use tracing::{error, info};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber;
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();
tracing_subscriber::registry() let db = postgres::connect().await;
.with( let redis = redis_helper::connect().await;
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), let state = AppState { db, redis };
)
.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) .with_state(state.clone())
.layer(DynamicCors); .layer(DynamicCors);
let listener = match TcpListener::bind(config.server.bind_address).await { let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
Ok(listener) => listener, let port: u16 = std::env::var("PORT")
Err(e) => { .unwrap_or_else(|_| "3000".to_string())
error!("Failed to bind to {}: {}", config.server.bind_address, e); .parse()
std::process::exit(1); .expect("PORT must be a number");
}
};
info!("Server listening on http://{}", config.server.bind_address); let addr = format!("{}:{}", host, port)
.parse::<SocketAddr>()
.expect("Invalid HOST or PORT");
let shutdown_signal = async { let listener = TcpListener::bind(addr)
tokio::signal::ctrl_c()
.await .await
.expect("Failed to install CTRL+C signal handler"); .expect("Failed to bind address");
warn!("Shutdown signal received");
};
if let Err(err) = serve(listener, app) info!("Listening on http://{}", addr);
.with_graceful_shutdown(shutdown_signal) if let Err(err) = serve(listener, app).await {
.await
{
error!("Server error: {}", err); error!("Server error: {}", err);
std::process::exit(1);
} }
info!("Server has shut down gracefully");
} }

View file

@ -1,4 +1,5 @@
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},
@ -10,8 +11,7 @@ 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; use std::{collections::HashMap, env};
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, Clone)] #[derive(Deserialize, Serialize)]
pub struct DiscordUser { pub struct DiscordUser {
pub id: String, pub id: String,
pub username: String, pub username: String,
@ -34,7 +34,6 @@ 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,
@ -57,20 +56,9 @@ pub async fn get_user_from_session(
)); ));
}; };
let mut redis_conn = state.redis.get_connection().await.map_err(|e| { let mut redis = state.redis.clone();
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 json: redis::RedisResult<String> = redis_conn.as_mut().get(&key).await; let Ok(json) = redis.get::<_, String>(&key).await else {
let Ok(json) = json else {
return Err(( return Err((
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(JsonMessage { Json(JsonMessage {
@ -88,17 +76,12 @@ pub async fn get_user_from_session(
)); ));
}; };
tracing::Span::current().record("user_id", &user.id);
Ok(user) Ok(user)
} }
#[instrument(skip(state))] pub async fn start_oauth(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
pub async fn start_oauth( let client_id = env::var("CLIENT_ID").unwrap_or_default();
State(state): State<AppState>, let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
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",
@ -109,18 +92,16 @@ pub async fn start_oauth(
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 = &state.config.discord.client_id; let client_id = env::var("CLIENT_ID").unwrap();
let client_secret = &state.config.discord.client_secret; let client_secret = env::var("CLIENT_SECRET").unwrap();
let redirect_uri = &state.config.discord.redirect_uri; let redirect_uri = env::var("REDIRECT_URI").unwrap();
let form = [ let form = [
("client_id", client_id.as_str()), ("client_id", client_id.as_str()),
@ -137,7 +118,6 @@ 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 {
@ -148,7 +128,6 @@ 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 {
@ -159,7 +138,6 @@ 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 {
@ -176,7 +154,6 @@ 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 {
@ -187,7 +164,6 @@ 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 {
@ -197,42 +173,15 @@ 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 mut redis_conn = match state.redis.get_connection().await { let _ = redis
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)
@ -256,11 +205,9 @@ 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) => {
@ -289,16 +236,13 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoR
})), })),
) )
.into_response(), .into_response(),
Err(e) => { Err(_) => (
error!("Database error while fetching timezone: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage { Json(JsonMessage {
message: "Failed to fetch timezone".into(), message: "Failed to fetch timezone".into(),
}), }),
) )
.into_response() .into_response(),
}
} }
} }
Err(err) => err.into_response(), Err(err) => err.into_response(),

View file

@ -1,31 +0,0 @@
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()
})),
)
}

View file

@ -2,14 +2,13 @@ use crate::db::AppState;
use axum::{ use axum::{
http::{HeaderValue, StatusCode}, http::{HeaderValue, StatusCode},
response::{Html, Response}, response::{Html, Response},
routing::{delete, get, options, post}, routing::{get, options},
Router, Router,
}; };
use std::fs; use std::fs;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
mod auth; pub mod auth;
mod health;
mod timezone; mod timezone;
async fn preflight_handler() -> Response { async fn preflight_handler() -> Response {
@ -19,7 +18,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, DELETE, OPTIONS"), HeaderValue::from_static("GET, POST, OPTIONS"),
); );
headers.insert( headers.insert(
"access-control-allow-headers", "access-control-allow-headers",
@ -47,14 +46,13 @@ 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", post(timezone::set_timezone)) .route("/set", get(timezone::set_timezone))
.route("/set", options(preflight_handler)) .route("/set", options(preflight_handler))
.route("/delete", delete(timezone::delete_timezone)) .route("/delete", get(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))
} }

View file

@ -5,7 +5,7 @@ use axum::{
extract::{Query, State}, extract::{Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
Form, Json, Json,
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use headers::{Cookie, HeaderMapExt}; use headers::{Cookie, HeaderMapExt};
@ -13,7 +13,6 @@ 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 {
@ -133,22 +132,9 @@ pub async fn delete_timezone(
.into_response(); .into_response();
}; };
let mut redis_conn = match state.redis.get_connection().await { let mut redis = state.redis.clone();
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_conn.get(&key).await; let json: redis::RedisResult<String> = redis.get(&key).await;
let Ok(json) = json else { let Ok(json) = json else {
return ( return (
@ -196,7 +182,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,
Form(query): Form<SetQuery>, Query(query): Query<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 (
@ -218,22 +204,9 @@ pub async fn set_timezone(
.into_response(); .into_response();
}; };
let mut redis_conn = match state.redis.get_connection().await { let mut redis = state.redis.clone();
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_conn.get(&key).await; let json: redis::RedisResult<String> = redis.get(&key).await;
let Ok(json) = json else { let Ok(json) = json else {
return ( return (