first commit
This commit is contained in:
commit
2da598738b
15 changed files with 3758 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
postgres-data
|
||||||
|
dragonfly-data
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/target
|
||||||
|
.sqlx
|
||||||
|
dragonfly-data
|
||||||
|
postgres-data
|
||||||
|
.env
|
2980
Cargo.lock
generated
Normal file
2980
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal 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
20
Dockerfile
Normal 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
75
README.md
Normal 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
48
compose.yml
Normal 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
11
src/db/mod.rs
Normal 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
27
src/db/postgres.rs
Normal 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
12
src/db/redis_helper.rs
Normal 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
47
src/main.rs
Normal 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
207
src/routes/auth.rs
Normal 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
16
src/routes/mod.rs
Normal 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
282
src/routes/timezone.rs
Normal 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
6
src/types.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct JsonMessage {
|
||||||
|
pub message: String,
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue