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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Avatar]()
+
Logged in
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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))
}