first commit

This commit is contained in:
creations 2025-05-31 18:20:37 -04:00
commit 2da598738b
Signed by: creations
GPG key ID: 8F553AA4320FC711
15 changed files with 3758 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
postgres-data
dragonfly-data

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
.sqlx
dragonfly-data
postgres-data
.env

2980
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "timezone-db"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "macros"] }
redis = { version = "0.31", features = ["tokio-comp", "aio"] }
uuid = { version = "1", features = ["v7"] }
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.12", features = ["json", "gzip"] }
tower-http = { version = "0.6.4", features = ["cors"] }
headers = "0.4.0"
chrono-tz = "0.10.3"

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
# Stage 1: Build
FROM rustlang/rust:nightly AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/timezone-db /usr/local/bin/app
ENV RUST_LOG=info
CMD ["/usr/local/bin/app"]

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# timezone-db
A simple Rust-powered API service for managing and retrieving user timezones.
## Features
- 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
- Built-in CORS support
- Fully containerized with PostgreSQL and DragonflyDB
## Requirements
- Docker & Docker Compose
- `.env` file with required environment variables
## Environment Variables
Create a `.env` file with the following:
```env
HOST=0.0.0.0
PORT=3000
DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
REDIS_URL=redis://dragonfly:6379
CLIENT_ID=your_discord_client_id
CLIENT_SECRET=your_discord_client_secret
REDIRECT_URI=https://your.domain/auth/discord/callback
```
## Setup
### Build and Run with Docker
```bash
docker compose up --build
```
## API Endpoints
### `GET /get?id=<discord_user_id>`
Returns stored timezone and username for the given user ID.
### `GET /set?timezone=<iana_timezone>`
Stores timezone for the authenticated user. Requires Discord OAuth session.
### `GET /delete`
Deletes the authenticated user's timezone entry. Requires Discord OAuth session.
### `GET /list`
Returns a JSON object of all stored timezones by user ID.
### `GET /me`
Returns Discord profile info for the current session.
### `GET /auth/discord`
Starts OAuth2 authentication flow.
### `GET /auth/discord/callback`
Handles OAuth2 redirect and sets a session cookie.
## License
[BSD-3-Clause](LICENSE)

48
compose.yml Normal file
View file

@ -0,0 +1,48 @@
services:
timezone-db:
container_name: timezoneDB
build:
context: .
restart: unless-stopped
ports:
- "${PORT:-3000}:${PORT:-3000}"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_started
networks:
- timezoneDB-network
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
- timezoneDB-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- ./dragonfly-data:/data
networks:
- timezoneDB-network
networks:
timezoneDB-network:
driver: bridge

11
src/db/mod.rs Normal file
View file

@ -0,0 +1,11 @@
pub mod postgres;
pub mod redis_helper;
pub type Db = sqlx::PgPool;
pub type Redis = redis::aio::MultiplexedConnection;
#[derive(Clone)]
pub struct AppState {
pub db: Db,
pub redis: Redis,
}

27
src/db/postgres.rs Normal file
View file

@ -0,0 +1,27 @@
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");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to connect to Postgres");
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS timezones (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
timezone TEXT NOT NULL
);
"#,
)
.execute(&pool)
.await
.expect("Failed to create timezones table");
pool
}

12
src/db/redis_helper.rs Normal file
View file

@ -0,0 +1,12 @@
use redis::Client;
use redis::aio::MultiplexedConnection;
use std::env;
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")
}

47
src/main.rs Normal file
View file

@ -0,0 +1,47 @@
use axum::{Router, serve};
use dotenvy::dotenv;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tracing::{error, info};
use tracing_subscriber;
mod db;
mod routes;
mod types;
use db::{AppState, postgres, redis_helper};
#[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 };
let app = Router::new()
.merge(routes::all())
.with_state(state.clone())
.layer(CorsLayer::permissive());
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 addr = format!("{}:{}", host, port)
.parse::<SocketAddr>()
.expect("Invalid HOST or PORT");
let listener = TcpListener::bind(addr)
.await
.expect("Failed to bind address");
info!("Listening on http://{}", addr);
if let Err(err) = serve(listener, app).await {
error!("Server error: {}", err);
}
}

207
src/routes/auth.rs Normal file
View file

@ -0,0 +1,207 @@
use crate::db::AppState;
use crate::types::JsonMessage;
use axum::{
Json,
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use headers::{Cookie, HeaderMapExt};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use std::env;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct CallbackQuery {
code: String,
}
#[derive(Deserialize, Serialize)]
pub struct DiscordUser {
pub id: String,
pub username: String,
pub discriminator: String,
pub avatar: Option<String>,
}
#[derive(Serialize)]
pub struct AuthResponse {
user: DiscordUser,
session: String,
}
pub async fn get_user_from_session(
headers: &HeaderMap,
state: &AppState,
) -> Result<DiscordUser, impl IntoResponse> {
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
return Err((
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session cookie".into(),
}),
));
};
let Some(session_id) = cookie_header.get("session") else {
return Err((
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session ID".into(),
}),
));
};
let mut redis = state.redis.clone();
let key = format!("session:{}", session_id);
let Ok(json) = redis.get::<_, String>(&key).await else {
return Err((
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Session not found".into(),
}),
));
};
let Ok(user) = serde_json::from_str::<DiscordUser>(&json) else {
return Err((
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Invalid user session".into(),
}),
));
};
Ok(user)
}
pub async fn start_oauth(State(_): State<AppState>) -> impl IntoResponse {
let client_id = env::var("CLIENT_ID").unwrap_or_default();
let redirect_uri = env::var("REDIRECT_URI").unwrap_or_default();
let url = format!(
"https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify",
client_id, redirect_uri
);
(StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response()
}
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 form = [
("client_id", client_id.as_str()),
("client_secret", client_secret.as_str()),
("grant_type", "authorization_code"),
("code", &query.code),
("redirect_uri", redirect_uri.as_str()),
];
let token_res = reqwest::Client::new()
.post("https://discord.com/api/oauth2/token")
.form(&form)
.send()
.await;
let Ok(res) = token_res else {
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
message: "Failed to exchange token".into(),
}),
)
.into_response();
};
let Ok(token_json) = res.json::<serde_json::Value>().await else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Invalid token response".into(),
}),
)
.into_response();
};
let Some(access_token) = token_json["access_token"].as_str() else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Access token not found".into(),
}),
)
.into_response();
};
let user_res = reqwest::Client::new()
.get("https://discord.com/api/users/@me")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await;
let Ok(user_res) = user_res else {
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
message: "Failed to fetch user".into(),
}),
)
.into_response();
};
let Ok(user) = user_res.json::<DiscordUser>().await else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to parse user".into(),
}),
)
.into_response();
};
let session_id = Uuid::now_v7().to_string();
let mut redis = state.redis.clone();
let _ = redis
.set_ex::<_, _, ()>(
format!("session:{}", session_id),
serde_json::to_string(&user).unwrap(),
3600,
)
.await;
let mut headers = HeaderMap::new();
headers.insert(
"Set-Cookie",
format!(
"session={}; Max-Age=3600; Path=/; SameSite=None; Secure; HttpOnly",
session_id
)
.parse()
.unwrap(),
);
(
StatusCode::OK,
headers,
Json(AuthResponse {
user,
session: session_id,
}),
)
.into_response()
}
pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
match get_user_from_session(&headers, &state).await {
Ok(user) => (StatusCode::OK, Json(user)).into_response(),
Err(err) => err.into_response(),
}
}

16
src/routes/mod.rs Normal file
View file

@ -0,0 +1,16 @@
use crate::db::AppState;
use axum::{Router, routing::get};
pub mod auth;
mod timezone;
pub fn all() -> Router<AppState> {
Router::new()
.route("/get", get(timezone::get_timezone))
.route("/set", get(timezone::set_timezone))
.route("/delete", get(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))
}

282
src/routes/timezone.rs Normal file
View file

@ -0,0 +1,282 @@
use crate::db::AppState;
use crate::routes::auth::DiscordUser;
use crate::types::JsonMessage;
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use chrono_tz::Tz;
use headers::{Cookie, HeaderMapExt};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::collections::HashMap;
#[derive(Serialize)]
pub struct TimezoneResponse {
user: UserInfo,
timezone: String,
}
#[derive(Serialize)]
struct MinimalUserInfo {
username: String,
timezone: String,
}
#[derive(Serialize)]
pub struct UserInfo {
id: String,
username: String,
}
#[derive(Deserialize)]
pub struct GetQuery {
id: String,
}
#[derive(Deserialize)]
pub struct SetQuery {
timezone: String,
}
pub async fn get_timezone(
State(state): State<AppState>,
Query(query): Query<GetQuery>,
) -> impl IntoResponse {
let row = sqlx::query("SELECT username, timezone FROM timezones WHERE user_id = $1")
.bind(&query.id)
.fetch_optional(&state.db)
.await;
match row {
Ok(Some(record)) => {
let response = TimezoneResponse {
user: UserInfo {
id: query.id,
username: record.get("username"),
},
timezone: record.get("timezone"),
};
(StatusCode::OK, Json(response)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(JsonMessage {
message: "User not found".into(),
}),
)
.into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Database error".into(),
}),
)
.into_response(),
}
}
pub async fn list_timezones(State(state): State<AppState>) -> impl IntoResponse {
let rows = sqlx::query("SELECT user_id, username, timezone FROM timezones")
.fetch_all(&state.db)
.await;
match rows {
Ok(data) => {
let mut result = HashMap::new();
for r in data {
result.insert(
r.get::<String, _>("user_id"),
MinimalUserInfo {
username: r.get("username"),
timezone: r.get("timezone"),
},
);
}
(StatusCode::OK, Json(result)).into_response()
}
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to fetch list".into(),
}),
)
.into_response(),
}
}
pub async fn delete_timezone(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session cookie".into(),
}),
)
.into_response();
};
let Some(session_id) = cookie_header.get("session") else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session ID".into(),
}),
)
.into_response();
};
let mut redis = state.redis.clone();
let key = format!("session:{}", session_id);
let json: redis::RedisResult<String> = redis.get(&key).await;
let Ok(json) = json else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Session not found".into(),
}),
)
.into_response();
};
let Ok(user) = serde_json::from_str::<DiscordUser>(&json) else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Invalid user session".into(),
}),
)
.into_response();
};
let result = sqlx::query("DELETE FROM timezones WHERE user_id = $1")
.bind(&user.id)
.execute(&state.db)
.await;
match result {
Ok(_) => (
StatusCode::OK,
Json(JsonMessage {
message: "Timezone deleted".into(),
}),
)
.into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Delete failed".into(),
}),
)
.into_response(),
}
}
pub async fn set_timezone(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<SetQuery>,
) -> impl IntoResponse {
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session cookie".into(),
}),
)
.into_response();
};
let Some(session_id) = cookie_header.get("session") else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Missing session ID".into(),
}),
)
.into_response();
};
let mut redis = state.redis.clone();
let key = format!("session:{}", session_id);
let json: redis::RedisResult<String> = redis.get(&key).await;
let Ok(json) = json else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Session not found".into(),
}),
)
.into_response();
};
let Ok(user) = serde_json::from_str::<DiscordUser>(&json) else {
return (
StatusCode::UNAUTHORIZED,
Json(JsonMessage {
message: "Invalid user session".into(),
}),
)
.into_response();
};
let tz_input = query.timezone.trim();
if tz_input.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
message: "Timezone is required".into(),
}),
)
.into_response();
}
if tz_input.parse::<Tz>().is_err() {
return (
StatusCode::BAD_REQUEST,
Json(JsonMessage {
message: "Invalid timezone".into(),
}),
)
.into_response();
}
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
"#,
)
.bind(&user.id)
.bind(&user.username)
.bind(tz_input)
.execute(&state.db)
.await;
match result {
Ok(_) => (
StatusCode::OK,
Json(JsonMessage {
message: "Timezone saved".into(),
}),
)
.into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Database error".into(),
}),
)
.into_response(),
}
}

6
src/types.rs Normal file
View file

@ -0,0 +1,6 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct JsonMessage {
pub message: String,
}