This commit is contained in:
pavel 2026-02-06 23:39:48 +01:00
commit 24e6ac26f1
7 changed files with 2354 additions and 197 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
.env

1899
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,5 +5,19 @@ edition = "2024"
[dependencies] [dependencies]
axum = { version = "0.8.8", features = ["ws"] } axum = { version = "0.8.8", features = ["ws"] }
dotenvy = "0.15.7"
futures = "0.3.31" futures = "0.3.31"
oauth2 = "4.4.2"
reqwest = { version = "0.13.2", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
time = "0.3.47"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
tower-sessions = "0.15.0"
[profile.release]
strip = true # Automatically strip symbols from the binary.
opt-level = "z" # Optimize for size.
lto = true # Enable Link Time Optimization
codegen-units = 1 # Maximize LTO
panic = "abort" # Abort on panic

View file

@ -1,21 +1,190 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Simple Chat</title> <title>AetherChat</title>
<style> <style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; } :root {
#chat { height: 400px; border: 1px solid #ccc; overflow-y: scroll; padding: 10px; margin-bottom: 10px; } --brass: #d4af37;
#controls { display: flex; gap: 10px; } --copper: #b87333;
#message { flex-grow: 1; padding: 5px; } --leather: #2b1d0e;
button { padding: 5px 15px; } --parchment: #f5e6d3;
--steam: #e0e0e0;
--gear-color: rgba(0, 0, 0, 0.2);
}
body {
font-family: 'Courier New', Courier, monospace;
background-color: #1a1a1a;
background-image:
radial-gradient(circle at 50% 50%, #2b1d0e 0%, #000 100%);
color: var(--parchment);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
h1 {
color: var(--brass);
text-shadow: 2px 2px 4px #000;
border-bottom: 2px solid var(--copper);
padding-bottom: 10px;
letter-spacing: 2px;
text-transform: uppercase;
}
#chat-container {
width: 100%;
max-width: 800px;
background: rgba(43, 29, 14, 0.9);
border: 4px solid var(--copper);
border-radius: 10px;
box-shadow:
0 0 15px var(--brass),
inset 0 0 20px #000;
padding: 20px;
display: flex;
flex-direction: column;
gap: 15px;
position: relative;
}
/* Decorative screws */
#chat-container::before,
#chat-container::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: var(--brass);
border-radius: 50%;
box-shadow: 1px 1px 2px #000;
}
#chat-container::before {
top: 10px;
left: 10px;
}
#chat-container::after {
top: 10px;
right: 10px;
}
#chat {
height: 500px;
overflow-y: scroll;
border: 2px inset var(--copper);
background: rgba(0, 0, 0, 0.3);
padding: 15px;
scrollbar-width: thin;
scrollbar-color: var(--brass) var(--leather);
}
#chat::-webkit-scrollbar {
width: 10px;
}
#chat::-webkit-scrollbar-track {
background: var(--leather);
}
#chat::-webkit-scrollbar-thumb {
background-color: var(--brass);
border: 1px solid var(--copper);
}
.message {
background-color: var(--parchment);
color: #2b1d0e;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid var(--copper);
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3);
font-weight: bold;
position: relative;
}
.message::after {
content: '';
position: absolute;
bottom: -5px;
left: 10px;
border-width: 5px 5px 0;
border-style: solid;
border-color: var(--copper) transparent;
}
.system-msg {
font-style: italic;
color: var(--brass);
text-align: center;
margin: 5px 0;
font-size: 0.9em;
background: none;
border: none;
box-shadow: none;
}
.system-msg::after {
display: none;
}
#controls {
display: flex;
gap: 10px;
padding: 10px;
background: #1a1a1a;
border: 1px solid var(--copper);
border-radius: 5px;
}
input[type="text"] {
flex-grow: 1;
padding: 10px;
background: var(--parchment);
border: 2px solid var(--leather);
color: #2b1d0e;
font-family: inherit;
font-weight: bold;
}
button {
background: linear-gradient(to bottom, var(--brass), var(--copper));
border: 2px solid #2b1d0e;
color: #2b1d0e;
padding: 10px 25px;
font-family: inherit;
font-weight: bold;
text-transform: uppercase;
cursor: pointer;
box-shadow: 2px 2px 4px #000;
transition: all 0.1s;
}
button:active {
transform: translate(2px, 2px);
box-shadow: none;
}
button:hover {
filter: brightness(1.2);
}
</style> </style>
</head> </head>
<body> <body>
<h1>Simple Chat</h1> <h1>&#9881; AetherChat &#9881;</h1>
<div id="chat"></div> <div id="chat-container">
<div id="controls"> <div id="chat"></div>
<input type="text" id="message" placeholder="Type a message..." autofocus> <div id="controls">
<button onclick="sendMessage()">Send</button> <input type="text" id="message" placeholder="Transmit payload..." autofocus>
<button onclick="sendMessage()">Engage</button>
</div>
</div> </div>
<script> <script>
@ -29,24 +198,32 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
const div = document.createElement("div"); const div = document.createElement("div");
div.textContent = event.data; div.textContent = event.data;
div.className = "message";
if (event.data.startsWith("System:")) {
div.className += " system-msg";
}
chatCheck.appendChild(div); chatCheck.appendChild(div);
chatCheck.scrollTop = chatCheck.scrollHeight; chatCheck.scrollTop = chatCheck.scrollHeight;
}; };
ws.onopen = () => { ws.onopen = () => {
const div = document.createElement("div"); addSystemMessage("Connected to the Aether-Net.");
div.textContent = "System: Connected to chat server";
div.style.color = "green";
chatCheck.appendChild(div);
}; };
ws.onclose = () => { ws.onclose = () => {
const div = document.createElement("div"); addSystemMessage("Connection severed. Check steam pressure.");
div.textContent = "System: Disconnected";
div.style.color = "red";
chatCheck.appendChild(div);
}; };
function addSystemMessage(text) {
const div = document.createElement("div");
div.textContent = `System: ${text}`;
div.className = "message system-msg";
chatCheck.appendChild(div);
chatCheck.scrollTop = chatCheck.scrollHeight;
}
function sendMessage() { function sendMessage() {
const msg = messageInput.value; const msg = messageInput.value;
if (msg) { if (msg) {
@ -60,4 +237,5 @@
}); });
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,80 +1,206 @@
use axum::{ use axum::{
extract::{ extract::{
ws::{Message, WebSocket, WebSocketUpgrade}, ws::{Message, WebSocket, WebSocketUpgrade},
State, Query, State,
}, },
response::{Html, IntoResponse}, http::StatusCode,
response::{Html, IntoResponse, Redirect},
routing::get, routing::get,
Router, Router,
}; };
use dotenvy::dotenv;
use futures::{sink::SinkExt, stream::StreamExt}; use futures::{sink::SinkExt, stream::StreamExt};
use oauth2::{
basic::BasicClient,AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenResponse,
TokenUrl,
};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tower_sessions::{cookie::SameSite, Expiry, MemoryStore, Session, SessionManagerLayer};
// Define application state to hold the broadcast channel mod user;
use user::User;
// Define application state
struct AppState { struct AppState {
tx: broadcast::Sender<String>, tx: broadcast::Sender<String>,
oauth_client: BasicClient,
} }
const AFTER_LOGIN_URL: &str = "/";
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Create a broadcast channel with a capacity of 100 messages dotenv().ok();
// Create a broadcast channel
let (tx, _rx) = broadcast::channel(100); let (tx, _rx) = broadcast::channel(100);
let app_state = Arc::new(AppState { tx });
// OAuth configuration
let client_id = dotenvy::var("CLIENT_ID").unwrap();
let client_secret = dotenvy::var("CLIENT_SECRET").unwrap();
// NOTE: In production, do not hardcode localhost
let auth_url = AuthUrl::new("https://idm.flegr.me/application/o/authorize/".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://idm.flegr.me/application/o/token/".to_string())
.expect("Invalid token endpoint URL");
let client = BasicClient::new(
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
auth_url,
Some(token_url),
)
.set_redirect_uri(
RedirectUrl::new("http://localhost:3001/auth/callback".to_string())
.expect("Invalid redirect URL"),
);
let app_state = Arc::new(AppState {
tx,
oauth_client: client,
});
// Session configuration
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // For localhost; set to true in production with HTTPS
.with_same_site(SameSite::Lax) // Ensure we can receive cookies from OAuth redirect
.with_expiry(Expiry::OnInactivity(time::Duration::minutes(30)));
// Build application with routes // Build application with routes
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/login", get(login))
.route("/logout", get(logout))
.route("/auth/callback", get(auth_callback))
.route("/ws", get(websocket_handler)) .route("/ws", get(websocket_handler))
.layer(session_layer)
.with_state(app_state); .with_state(app_state);
// Run the app using hyper, listening globally on port 3000 // Run the app
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
println!("Chat server listening on http://0.0.0.0:3001"); println!("Chat server listening on http://0.0.0.0:3001");
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
// Handler to serve the HTML file // Handlers
async fn index() -> Html<&'static str> {
Html(include_str!("../index.html")) async fn index(session: Session) -> impl IntoResponse {
if let Some(_user) = session.get::<User>("user").await.unwrap() {
return Html(include_str!("../index.html")).into_response();
}
Redirect::to("/login").into_response()
}
async fn login(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let (auth_url, _csrf_token) = state
.oauth_client
.authorize_url(oauth2::CsrfToken::new_random)
.url();
Redirect::to(auth_url.as_str())
}
async fn logout(session: Session) -> impl IntoResponse {
session.delete().await.unwrap();
Redirect::to("/login")
}
#[derive(serde::Deserialize)]
struct AuthRequest {
code: String,
state: String,
}
async fn auth_callback(
Query(query): Query<AuthRequest>,
State(state): State<Arc<AppState>>,
session: Session,
) -> impl IntoResponse {
let token = match state
.oauth_client
.exchange_code(oauth2::AuthorizationCode::new(query.code))
.request_async(oauth2::reqwest::async_http_client)
.await
{
Ok(t) => t,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Failed to exchange authorization code: {}", e),
)
.into_response();
}
};
let client = reqwest::Client::new();
let user_info_resp = client
.get("https://idm.flegr.me/application/o/userinfo/")
.header("User-Agent", "axum-chat-app")
.bearer_auth(token.access_token().secret())
.send()
.await;
let user_data: User = match user_info_resp {
Ok(resp) => match resp.json().await {
Ok(u) => u,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse user info: {}", e)
).into_response()
},
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch user info: {}", e)
).into_response()
};
session.insert("user", user_data).await.unwrap();
Redirect::to(AFTER_LOGIN_URL).into_response()
} }
// Handler to upgrade HTTP connection to WebSocket
async fn websocket_handler( async fn websocket_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
session: Session,
) -> impl IntoResponse { ) -> impl IntoResponse {
ws.on_upgrade(|socket| websocket(socket, state)) if let Some(user) = session.get::<User>("user").await.unwrap() {
return ws.on_upgrade(move |socket| websocket(socket, state, user));
}
StatusCode::UNAUTHORIZED.into_response()
} }
// The actual WebSocket connection handler // WebSocket handler
async fn websocket(stream: WebSocket, state: Arc<AppState>) { async fn websocket(stream: WebSocket, state: Arc<AppState>, user: User) {
// Split the stream into sender and receiver
let (mut sender, mut receiver) = stream.split(); let (mut sender, mut receiver) = stream.split();
// Subscribe to the broadcast channel
let mut rx = state.tx.subscribe(); let mut rx = state.tx.subscribe();
// Spawn a task to send messages from the broadcast channel to this client // Send welcome message
let msg = format!("System: Welcome, {}!", user.login);
let _ = sender.send(Message::Text(msg.into())).await;
// Send task
let mut send_task = tokio::spawn(async move { let mut send_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await { while let Ok(msg) = rx.recv().await {
// In a real app, you might want to handle errors better
if sender.send(Message::Text(msg.into())).await.is_err() { if sender.send(Message::Text(msg.into())).await.is_err() {
break; break;
} }
} }
}); });
// Spawn a task to receive messages from this client and broadcast them // Receive task
let tx = state.tx.clone(); let tx = state.tx.clone();
let username = user.login.clone();
let mut recv_task = tokio::spawn(async move { let mut recv_task = tokio::spawn(async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await { while let Some(Ok(Message::Text(text))) = receiver.next().await {
// Broadcast the message to all subscribers // Prepend username
let _ = tx.send(text.to_string()); let msg = format!("{}: {}", username, text);
let _ = tx.send(msg);
} }
}); });
// If either task finishes (connection closed or error), abort the other
tokio::select! { tokio::select! {
_ = (&mut send_task) => recv_task.abort(), _ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(), _ = (&mut recv_task) => send_task.abort(),

9
src/user.rs Normal file
View file

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
#[serde(alias = "preferred_username")]
pub login: String,
#[serde(default)]
pub avatar_url: String,
}