Compare commits

..

5 commits
main ... dev

Author SHA1 Message Date
c50dc78c9b
shouldnt have ingored cargo lock 2025-06-04 08:51:24 -04:00
6bfd298455
# Git Commit Message
refactor: add production features and improve architecture

- Add structured configuration with validation
- Implement Redis connection pooling
- Add database migrations system
- Change API methods: GET /set → POST /set, GET /delete → DELETE /delete
- Add health check endpoint
- Add graceful shutdown and structured logging
- Update frontend for new API methods
- Add open source attribution
2025-06-04 07:56:15 -04:00
ad6c9b7095
add open source to index page 2025-06-02 18:22:54 -04:00
1fa52e74e6
test 2025-06-01 17:29:45 -04:00
13159e5d78
move to the proper HTTP methods 2025-06-01 08:34:17 -04:00
25 changed files with 1060 additions and 168 deletions

View file

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

21
.env.example Normal file
View 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
View file

@ -2,4 +2,4 @@
.sqlx
dragonfly-data
postgres-data
.env
.env

162
Cargo.lock generated
View file

@ -32,6 +32,21 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "async-compression"
version = "0.4.23"
@ -135,12 +150,6 @@ dependencies = [
"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]]
name = "base64"
version = "0.22.1"
@ -210,7 +219,13 @@ version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
@ -648,11 +663,11 @@ dependencies = [
[[package]]
name = "headers"
version = "0.4.0"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64 0.21.7",
"base64",
"bytes",
"headers-core",
"http",
@ -820,7 +835,7 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8"
dependencies = [
"base64 0.22.1",
"base64",
"bytes",
"futures-channel",
"futures-core",
@ -840,6 +855,30 @@ dependencies = [
"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]]
name = "icu_collections"
version = "2.0.0"
@ -1048,6 +1087,15 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "matchit"
version = "0.8.4"
@ -1496,8 +1544,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
"regex-automata 0.4.9",
"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]]
@ -1508,9 +1565,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"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]]
name = "regex-syntax"
version = "0.8.5"
@ -1519,12 +1582,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.18"
version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"async-compression",
"base64 0.22.1",
"base64",
"bytes",
"encoding_rs",
"futures-core",
@ -1886,8 +1949,9 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"base64",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
@ -1959,10 +2023,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"base64",
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"digest",
"dotenvy",
@ -2001,9 +2066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"base64",
"bitflags",
"byteorder",
"chrono",
"crc",
"dotenvy",
"etcetera",
@ -2038,6 +2104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
"flume",
"futures-channel",
"futures-core",
@ -2178,6 +2245,7 @@ name = "timezone-db"
version = "0.1.0"
dependencies = [
"axum",
"chrono",
"chrono-tz",
"dotenvy",
"headers",
@ -2186,6 +2254,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"thiserror",
"tokio",
"tower",
"tower-http",
@ -2311,12 +2380,13 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.4"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
@ -2398,10 +2468,14 @@ version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
@ -2652,6 +2726,41 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows-link"
version = "0.1.1"
@ -2665,7 +2774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result",
"windows-strings",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
]
@ -2687,6 +2796,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.48.0"

View file

@ -5,18 +5,25 @@ edition = "2021"
[dependencies]
axum = "0.8.4"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1", features = ["full", "signal"] }
sqlx = { version = "0.8.6", features = [
"postgres",
"runtime-tokio",
"macros",
"chrono",
] }
redis = { version = "0.31", features = ["tokio-comp", "aio"] }
uuid = { version = "1", features = ["v7"] }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.12", features = ["json", "gzip"] }
tower-http = { version = "0.6.4", features = ["cors", "fs"] }
headers = "0.4.0"
chrono-tz = "0.10.3"
chrono = { version = "0.4", features = ["serde"] }
tower = "0.5.2"
urlencoding = "2.1.3"
thiserror = "2.0.12"

View file

@ -1,4 +1,3 @@
# Stage 1: Build
FROM rustlang/rust:nightly AS builder
WORKDIR /app
@ -6,6 +5,7 @@ WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY public ./public
COPY migrations ./migrations
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/public ./public
ENV RUST_LOG=info
COPY --from=builder /app/migrations ./migrations
CMD ["/usr/local/bin/app"]

28
LICENSE
View file

@ -1,28 +0,0 @@
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,8 +7,10 @@ A simple Rust-powered API service for managing and retrieving user timezones.
- Store user timezones via `/set` endpoint (requires Discord OAuth)
- Retrieve timezones by user ID via `/get`
- List all saved timezones
- Cookie-based session handling using Redis
- Cookie-based session handling using Redis connection pooling
- Built-in CORS support
- Structured configuration with validation
- Graceful shutdown support
- Fully containerized with PostgreSQL and DragonflyDB
## Requirements
@ -21,15 +23,27 @@ A simple Rust-powered API service for managing and retrieving user timezones.
Create a `.env` file with the following:
```env
# Server Configuration
HOST=0.0.0.0
PORT=3000
# Database Configuration
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_SECRET=your_discord_client_secret
REDIRECT_URI=https://your.domain/auth/discord/callback
# Logging (optional)
RUST_LOG=info,timezone_db=debug
```
## Setup
@ -40,17 +54,36 @@ REDIRECT_URI=https://your.domain/auth/discord/callback
docker compose up --build
```
### Run Manually
```bash
# Make sure PostgreSQL and Redis are running
cargo run
```
## API Endpoints
### `GET /get?id=<discord_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.
@ -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.
**Response:**
```json
{
"123456789": {
"username": "user1",
"timezone": "America/New_York"
},
"987654321": {
"username": "user2",
"timezone": "Europe/London"
}
}
```
### `GET /me`
Returns Discord profile info for the current session.
Returns Discord profile info and timezone for the current session.
### `GET /auth/discord`
Starts OAuth2 authentication flow.
Starts OAuth2 authentication flow. Supports optional `?redirect=` parameter.
### `GET /auth/discord/callback`
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

View file

@ -15,6 +15,12 @@ services:
condition: service_started
networks:
- timezoneDB-network
healthcheck:
test: ["CMD", "curl", "-f", "http://${HOST:-localhost}:${PORT:-3000}/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
postgres:
image: postgres:16
@ -23,6 +29,7 @@ services:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
PGUSER: postgres
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
@ -45,4 +52,4 @@ services:
networks:
timezoneDB-network:
driver: bridge
driver: bridge

View 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);

View 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();

View file

@ -164,6 +164,25 @@ button.danger:hover {
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 {
display: none;

36
public/forgejo_logo.svg Normal file
View 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

View file

@ -14,6 +14,14 @@
</head>
<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>
<section id="login-section" class="hidden">
<h1>Timezone DB</h1>

View file

@ -7,18 +7,19 @@ const setBtn = document.getElementById("set-timezone");
const statusMsg = document.getElementById("status-msg");
const timezones = Intl.supportedValuesOf("timeZone");
timezones.forEach(tz => {
for (const tz of timezones) {
const opt = document.createElement("option");
opt.value = tz;
opt.textContent = tz;
timezoneSelect.appendChild(opt);
});
}
const ts = new TomSelect("#timezone-select", {
create: false,
sorted: true,
searchField: ["text"],
maxOptions: 1000
maxOptions: 1000,
});
async function fetchUserInfo() {
@ -52,7 +53,11 @@ async function fetchUserInfo() {
deleteBtn.addEventListener("click", async () => {
try {
const res = await fetch("/delete", { credentials: "include" });
const res = await fetch("/delete", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error();
ts.clear();
@ -62,7 +67,6 @@ async function fetchUserInfo() {
statusMsg.textContent = "Failed to delete timezone.";
}
});
} catch {
loginSection.classList.remove("hidden");
timezoneSection.classList.add("hidden");
@ -73,14 +77,30 @@ setBtn.addEventListener("click", async () => {
const timezone = ts.getValue();
if (!timezone) return;
setBtn.disabled = true;
setBtn.textContent = "Saving...";
statusMsg.textContent = "";
try {
const res = await fetch(`/set?timezone=${encodeURIComponent(timezone)}`, {
const res = await fetch("/set", {
method: "POST",
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!";
} catch {
statusMsg.textContent = "Failed to update timezone.";
document.getElementById("delete-timezone").classList.remove("hidden");
} catch (error) {
statusMsg.textContent = error.message;
} finally {
setBtn.disabled = false;
setBtn.textContent = "Save";
}
});

9
rustfmt.toml Normal file
View 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
View 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()))
}

View file

@ -1,11 +1,23 @@
pub mod postgres;
pub mod redis_helper;
use crate::config::Config;
pub use redis_helper::RedisPool;
pub type Db = sqlx::PgPool;
pub type Redis = redis::aio::MultiplexedConnection;
#[derive(Clone)]
pub struct AppState {
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()
}
}

View file

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

View file

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

View file

@ -1,48 +1,104 @@
use axum::{serve, Router};
use dotenvy::dotenv;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing::{error, info};
use tracing_subscriber;
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod db;
mod middleware;
mod routes;
mod types;
use config::Config;
use db::{postgres, redis_helper, AppState};
use middleware::cors::DynamicCors;
#[tokio::main]
async fn main() {
dotenv().ok();
tracing_subscriber::fmt::init();
let db = postgres::connect().await;
let redis = redis_helper::connect().await;
let state = AppState { db, redis };
tracing_subscriber::registry()
.with(
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()
.merge(routes::all())
.with_state(state.clone())
.with_state(state)
.layer(DynamicCors);
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a number");
let listener = match TcpListener::bind(config.server.bind_address).await {
Ok(listener) => listener,
Err(e) => {
error!("Failed to bind to {}: {}", config.server.bind_address, e);
std::process::exit(1);
}
};
let addr = format!("{}:{}", host, port)
.parse::<SocketAddr>()
.expect("Invalid HOST or PORT");
info!("Server listening on http://{}", config.server.bind_address);
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
.expect("Failed to bind address");
info!("Listening on http://{}", addr);
if let Err(err) = serve(listener, app).await {
{
error!("Server error: {}", err);
std::process::exit(1);
}
info!("Server has shut down gracefully");
}

View file

@ -1,5 +1,4 @@
use crate::db::AppState;
use crate::types::JsonMessage;
use axum::{
extract::{Query, State},
@ -11,7 +10,8 @@ use headers::{Cookie, HeaderMapExt};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::{collections::HashMap, env};
use std::collections::HashMap;
use tracing::{error, info, instrument};
use uuid::Uuid;
#[derive(Deserialize)]
@ -20,7 +20,7 @@ pub struct CallbackQuery {
state: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[derive(Deserialize, Serialize, Clone)]
pub struct DiscordUser {
pub id: String,
pub username: String,
@ -34,6 +34,7 @@ pub struct AuthResponse {
session: String,
}
#[instrument(skip(state), fields(user_id))]
pub async fn get_user_from_session(
headers: &HeaderMap,
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 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((
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
@ -76,32 +88,39 @@ pub async fn get_user_from_session(
));
};
tracing::Span::current().record("user_id", &user.id);
Ok(user)
}
pub async fn start_oauth(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let client_id = env::var("CLIENT_ID").unwrap_or_default();
let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
#[instrument(skip(state))]
pub async fn start_oauth(
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!(
"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
client_id, redirect_uri
);
"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
client_id, redirect_uri
);
if let Some(redirect) = params.get("redirect") {
url.push_str(&format!("&state={}", urlencoding::encode(redirect)));
}
info!("Starting OAuth flow");
(StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response()
}
#[instrument(skip(state, query), fields(user_id))]
pub async fn handle_callback(
State(state): State<AppState>,
Query(query): Query<CallbackQuery>,
) -> impl IntoResponse {
let client_id = env::var("CLIENT_ID").unwrap();
let client_secret = env::var("CLIENT_SECRET").unwrap();
let redirect_uri = env::var("REDIRECT_URI").unwrap();
let client_id = &state.config.discord.client_id;
let client_secret = &state.config.discord.client_secret;
let redirect_uri = &state.config.discord.redirect_uri;
let form = [
("client_id", client_id.as_str()),
@ -118,6 +137,7 @@ pub async fn handle_callback(
.await;
let Ok(res) = token_res else {
error!("Failed to exchange OAuth code for token");
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
@ -128,6 +148,7 @@ pub async fn handle_callback(
};
let Ok(token_json) = res.json::<serde_json::Value>().await else {
error!("Invalid token response from Discord");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
@ -138,6 +159,7 @@ pub async fn handle_callback(
};
let Some(access_token) = token_json["access_token"].as_str() else {
error!("Access token not found in Discord response");
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
@ -154,6 +176,7 @@ pub async fn handle_callback(
.await;
let Ok(user_res) = user_res else {
error!("Failed to fetch user info from Discord");
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
@ -164,6 +187,7 @@ pub async fn handle_callback(
};
let Ok(user) = user_res.json::<DiscordUser>().await else {
error!("Failed to parse user info from Discord");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
@ -173,15 +197,42 @@ pub async fn handle_callback(
.into_response();
};
tracing::Span::current().record("user_id", &user.id);
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::<_, _, ()>(
format!("session:{}", session_id),
serde_json::to_string(&user).unwrap(),
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 {
Some(s) => urlencoding::decode(s)
@ -205,9 +256,11 @@ pub async fn handle_callback(
redirect_target.parse().unwrap(),
);
info!(user_id = %user.id, username = %user.username, "User logged in successfully");
(StatusCode::FOUND, headers).into_response()
}
#[instrument(skip(state))]
pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
match get_user_from_session(&headers, &state).await {
Ok(user) => {
@ -236,13 +289,16 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoR
})),
)
.into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to fetch timezone".into(),
}),
)
.into_response(),
Err(e) => {
error!("Database error while fetching timezone: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to fetch timezone".into(),
}),
)
.into_response()
}
}
}
Err(err) => err.into_response(),

31
src/routes/health.rs Normal file
View 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()
})),
)
}

View file

@ -2,13 +2,14 @@ use crate::db::AppState;
use axum::{
http::{HeaderValue, StatusCode},
response::{Html, Response},
routing::{get, options},
routing::{delete, get, options, post},
Router,
};
use std::fs;
use tower_http::services::ServeDir;
pub mod auth;
mod auth;
mod health;
mod timezone;
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-methods",
HeaderValue::from_static("GET, POST, OPTIONS"),
HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
);
headers.insert(
"access-control-allow-headers",
@ -46,13 +47,14 @@ pub fn all() -> Router<AppState> {
Router::new()
.route("/", get(index_page))
.route("/get", get(timezone::get_timezone))
.route("/set", get(timezone::set_timezone))
.route("/set", post(timezone::set_timezone))
.route("/set", options(preflight_handler))
.route("/delete", get(timezone::delete_timezone))
.route("/delete", delete(timezone::delete_timezone))
.route("/list", get(timezone::list_timezones))
.route("/auth/discord", get(auth::start_oauth))
.route("/auth/discord/callback", get(auth::handle_callback))
.route("/me", get(auth::me))
.route("/health", get(health::health_check))
.nest_service("/public", ServeDir::new("public"))
.fallback(get(index_page))
}

View file

@ -5,7 +5,7 @@ use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
Form, Json,
};
use chrono_tz::Tz;
use headers::{Cookie, HeaderMapExt};
@ -13,6 +13,7 @@ use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::collections::HashMap;
use tracing::error;
#[derive(Serialize)]
pub struct TimezoneResponse {
@ -132,9 +133,22 @@ pub async fn delete_timezone(
.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 json: redis::RedisResult<String> = redis.get(&key).await;
let json: redis::RedisResult<String> = redis_conn.get(&key).await;
let Ok(json) = json else {
return (
@ -182,7 +196,7 @@ pub async fn delete_timezone(
pub async fn set_timezone(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<SetQuery>,
Form(query): Form<SetQuery>,
) -> impl IntoResponse {
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
return (
@ -204,9 +218,22 @@ pub async fn set_timezone(
.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 json: redis::RedisResult<String> = redis.get(&key).await;
let json: redis::RedisResult<String> = redis_conn.get(&key).await;
let Ok(json) = json else {
return (
@ -251,11 +278,11 @@ pub async fn set_timezone(
let result = sqlx::query(
r#"
INSERT INTO timezones (user_id, username, timezone)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET username = EXCLUDED.username, timezone = EXCLUDED.timezone
"#,
INSERT INTO timezones (user_id, username, timezone)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET username = EXCLUDED.username, timezone = EXCLUDED.timezone
"#,
)
.bind(&user.id)
.bind(&user.username)