redesign
This commit is contained in:
parent
1fb689fb8f
commit
24e6ac26f1
7 changed files with 2354 additions and 197 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
GITHUB_CLIENT_ID=your_client_id
|
||||||
|
GITHUB_CLIENT_SECRET=your_client_secret
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
.env
|
||||||
1899
Cargo.lock
generated
1899
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
|
@ -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
|
||||||
|
|
|
||||||
212
index.html
212
index.html
|
|
@ -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>⚙ AetherChat ⚙</h1>
|
||||||
|
<div id="chat-container">
|
||||||
<div id="chat"></div>
|
<div id="chat"></div>
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<input type="text" id="message" placeholder="Type a message..." autofocus>
|
<input type="text" id="message" placeholder="Transmit payload..." autofocus>
|
||||||
<button onclick="sendMessage()">Send</button>
|
<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>
|
||||||
170
src/main.rs
170
src/main.rs
|
|
@ -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
9
src/user.rs
Normal 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,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue