diff --git a/Cargo.lock b/Cargo.lock index 8a192ec..8cb5347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,6 +743,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1070,6 +1076,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -2175,6 +2191,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] @@ -2303,11 +2320,20 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2392,6 +2418,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2436,6 +2468,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index fb99e20..b1b5e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,8 @@ 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"] } +tower-http = { version = "0.6.4", features = ["cors", "fs"] } headers = "0.4.0" chrono-tz = "0.10.3" tower = "0.5.2" +urlencoding = "2.1.3" diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..b116f25 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,170 @@ +:root { + --bg: #0d0d0d; + --fg: #f5f5f5; + --accent: #4ea3ff; + --card: #1c1c1c; + --border: #333; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: system-ui, sans-serif; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 2rem; +} + +main { + width: 100%; + max-width: 420px; + background: var(--card); + padding: 2rem; + border-radius: 1rem; + box-shadow: 0 0 10px #0008; + border: 1px solid var(--border); +} + +h1, +h2 { + text-align: center; + margin-bottom: 1rem; + color: var(--accent); +} + +.profile { + text-align: center; + margin-bottom: 1.5rem; +} + +#avatar { + width: 80px; + height: 80px; + border-radius: 50%; + border: 2px solid var(--border); + margin-bottom: 0.5rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +select, +button { + width: 100%; + padding: 0.6rem; + margin-top: 0.25rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + font-size: 1rem; + background: var(--bg); + color: var(--fg); +} + +select:focus, +button:hover { + border-color: var(--accent); + outline: none; +} + +#set-timezone { + background: var(--accent); + color: white; + margin-top: 1rem; + cursor: pointer; + transition: background 0.2s ease; +} + +#set-timezone:hover { + background: #2c8de6; +} + +a#login-btn { + display: block; + text-align: center; + padding: 0.75rem; + margin-top: 1rem; + background: var(--accent); + color: white; + font-weight: bold; + border-radius: 0.5rem; + text-decoration: none; + transition: background 0.2s ease; +} + +a#login-btn:hover { + background: #2c8de6; +} + +#status-msg { + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; + color: var(--accent); +} + +button.danger { + background: #ff4e4e; + color: white; +} + +button.danger:hover { + background: #e63838; +} + +.ts-wrapper.single .ts-control { + background-color: var(--card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.6rem; + font-size: 1rem; + box-shadow: none; +} + +.ts-wrapper.single.focus .ts-control { + background-color: var(--card); + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(78, 163, 255, 0.25); +} + +.ts-dropdown { + background-color: var(--card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 0.5rem; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +.ts-dropdown .option { + padding: 0.5rem 0.75rem; + cursor: pointer; + transition: background 0.2s; +} + +.ts-dropdown .option:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.ts-dropdown .option.active { + background-color: var(--accent); + color: #fff; +} + +.ts-wrapper .ts-control input { + color: var(--fg); +} + + +.hidden { + display: none; +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5354c51 --- /dev/null +++ b/public/index.html @@ -0,0 +1,41 @@ + + + + + + + Timezone DB + + + + + + + + + +
+ + + +
+ + + \ No newline at end of file diff --git a/public/js/index.js b/public/js/index.js new file mode 100644 index 0000000..a342ffb --- /dev/null +++ b/public/js/index.js @@ -0,0 +1,87 @@ +const loginSection = document.getElementById("login-section"); +const timezoneSection = document.getElementById("timezone-section"); +const avatarEl = document.getElementById("avatar"); +const authStatusEl = document.getElementById("auth-status"); +const timezoneSelect = document.getElementById("timezone-select"); +const setBtn = document.getElementById("set-timezone"); +const statusMsg = document.getElementById("status-msg"); + +const timezones = Intl.supportedValuesOf("timeZone"); +timezones.forEach(tz => { + 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 +}); + +async function fetchUserInfo() { + try { + const res = await fetch("/me", { credentials: "include" }); + if (!res.ok) throw new Error(); + + const json = await res.json(); + const user = json.user; + const tz = json.timezone; + + authStatusEl.textContent = user.username; + + if (user.avatar) { + avatarEl.src = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`; + avatarEl.classList.remove("hidden"); + } + + loginSection.classList.add("hidden"); + timezoneSection.classList.remove("hidden"); + + const deleteBtn = document.getElementById("delete-timezone"); + + if (tz) { + ts.setValue(tz); + deleteBtn.classList.remove("hidden"); + } else { + ts.clear(); + deleteBtn.classList.add("hidden"); + } + + deleteBtn.addEventListener("click", async () => { + try { + const res = await fetch("/delete", { credentials: "include" }); + if (!res.ok) throw new Error(); + + ts.clear(); + statusMsg.textContent = "Timezone deleted."; + deleteBtn.classList.add("hidden"); + } catch { + statusMsg.textContent = "Failed to delete timezone."; + } + }); + + } catch { + loginSection.classList.remove("hidden"); + timezoneSection.classList.add("hidden"); + } +} + +setBtn.addEventListener("click", async () => { + const timezone = ts.getValue(); + if (!timezone) return; + + try { + const res = await fetch(`/set?timezone=${encodeURIComponent(timezone)}`, { + credentials: "include", + }); + if (!res.ok) throw new Error(); + statusMsg.textContent = "Timezone updated!"; + } catch { + statusMsg.textContent = "Failed to update timezone."; + } +}); + +fetchUserInfo(); diff --git a/src/db/redis_helper.rs b/src/db/redis_helper.rs index 6fb8049..1e7d19e 100644 --- a/src/db/redis_helper.rs +++ b/src/db/redis_helper.rs @@ -1,5 +1,5 @@ -use redis::Client; use redis::aio::MultiplexedConnection; +use redis::Client; use std::env; pub async fn connect() -> MultiplexedConnection { diff --git a/src/routes/auth.rs b/src/routes/auth.rs index c1bc5c1..1319790 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -2,20 +2,22 @@ use crate::db::AppState; use crate::types::JsonMessage; use axum::{ - Json, extract::{Query, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, + Json, }; use headers::{Cookie, HeaderMapExt}; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; -use std::env; +use sqlx::Row; +use std::{collections::HashMap, env}; use uuid::Uuid; #[derive(Deserialize)] pub struct CallbackQuery { code: String, + state: Option, } #[derive(Deserialize, Serialize)] @@ -77,14 +79,18 @@ pub async fn get_user_from_session( Ok(user) } -pub async fn start_oauth(State(_): State) -> impl IntoResponse { +pub async fn start_oauth(Query(params): Query>) -> 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 - ); + let mut url = format!( + "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))); + } (StatusCode::FOUND, [(axum::http::header::LOCATION, url)]).into_response() } @@ -177,6 +183,13 @@ pub async fn handle_callback( ) .await; + let redirect_target = match &query.state { + Some(s) => urlencoding::decode(s) + .map(|s| s.into_owned()) + .unwrap_or("/".to_string()), + None => "/".to_string(), + }; + let mut headers = HeaderMap::new(); headers.insert( "Set-Cookie", @@ -187,21 +200,51 @@ pub async fn handle_callback( .parse() .unwrap(), ); + headers.insert( + axum::http::header::LOCATION, + redirect_target.parse().unwrap(), + ); - ( - StatusCode::OK, - headers, - Json(AuthResponse { - user, - session: session_id, - }), - ) - .into_response() + (StatusCode::FOUND, headers).into_response() } pub async fn me(State(state): State, headers: HeaderMap) -> impl IntoResponse { match get_user_from_session(&headers, &state).await { - Ok(user) => (StatusCode::OK, Json(user)).into_response(), + Ok(user) => { + let result = sqlx::query("SELECT timezone FROM timezones WHERE user_id = $1") + .bind(&user.id) + .fetch_optional(&state.db) + .await; + + match result { + Ok(Some(row)) => { + let timezone: String = row.get("timezone"); + ( + StatusCode::OK, + Json(serde_json::json!({ + "user": user, + "timezone": timezone + })), + ) + .into_response() + } + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "user": user, + "timezone": null + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(JsonMessage { + message: "Failed to fetch timezone".into(), + }), + ) + .into_response(), + } + } Err(err) => err.into_response(), } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8da7fbd..e90d36f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,10 +1,12 @@ use crate::db::AppState; use axum::{ http::{HeaderValue, StatusCode}, - response::Response, + response::{Html, Response}, routing::{get, options}, Router, }; +use std::fs; +use tower_http::services::ServeDir; pub mod auth; mod timezone; @@ -33,8 +35,16 @@ async fn preflight_handler() -> Response { res } +async fn index_page() -> Html { + Html( + fs::read_to_string("public/index.html") + .unwrap_or_else(|_| "

404 Not Found

".to_string()), + ) +} + pub fn all() -> Router { Router::new() + .route("/", get(index_page)) .route("/get", get(timezone::get_timezone)) .route("/set", get(timezone::set_timezone)) .route("/set", options(preflight_handler)) @@ -43,4 +53,6 @@ pub fn all() -> Router { .route("/auth/discord", get(auth::start_oauth)) .route("/auth/discord/callback", get(auth::handle_callback)) .route("/me", get(auth::me)) + .nest_service("/public", ServeDir::new("public")) + .fallback(get(index_page)) }