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