individual messages
This commit is contained in:
parent
b098214a9d
commit
7fdf18ff1b
4 changed files with 617 additions and 312 deletions
160
Cargo.lock
generated
160
Cargo.lock
generated
|
|
@ -11,17 +11,6 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-trait"
|
|
||||||
version = "0.1.89"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
|
|
@ -199,13 +188,13 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"jsonwebtoken",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-sessions",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -241,17 +230,6 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cookie"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
|
||||||
dependencies = [
|
|
||||||
"percent-encoding",
|
|
||||||
"time",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
|
|
@ -310,7 +288,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -937,6 +914,21 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.180"
|
||||||
|
|
@ -999,12 +991,31 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -1069,6 +1080,16 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -1639,6 +1660,18 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror 2.0.14",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -1947,22 +1980,6 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower-cookies"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
|
|
||||||
dependencies = [
|
|
||||||
"axum-core",
|
|
||||||
"cookie",
|
|
||||||
"futures-util",
|
|
||||||
"http 1.4.0",
|
|
||||||
"parking_lot",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|
@ -1993,57 +2010,6 @@ version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower-sessions"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"http 1.4.0",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tower-cookies",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
"tower-sessions-core",
|
|
||||||
"tower-sessions-memory-store",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower-sessions-core"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum-core",
|
|
||||||
"base64 0.22.1",
|
|
||||||
"futures",
|
|
||||||
"http 1.4.0",
|
|
||||||
"parking_lot",
|
|
||||||
"rand 0.9.2",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.14",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower-sessions-memory-store"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tower-sessions-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -2052,21 +2018,9 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-attributes"
|
|
||||||
version = "0.1.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,9 @@ axum = { version = "0.8.8", features = ["ws"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
oauth2 = "4.4.2"
|
oauth2 = "4.4.2"
|
||||||
|
jsonwebtoken = "9.3.0"
|
||||||
reqwest = { version = "0.13.2", features = ["json"] }
|
reqwest = { version = "0.13.2", features = ["json"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
time = "0.3.47"
|
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
|
|
||||||
|
|
|
||||||
446
index.html
446
index.html
|
|
@ -11,6 +11,11 @@
|
||||||
--parchment: #f5e6d3;
|
--parchment: #f5e6d3;
|
||||||
--steam: #e0e0e0;
|
--steam: #e0e0e0;
|
||||||
--gear-color: rgba(0, 0, 0, 0.2);
|
--gear-color: rgba(0, 0, 0, 0.2);
|
||||||
|
--online-green: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -34,11 +39,97 @@
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-container {
|
#main-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 1000px;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contacts Sidebar */
|
||||||
|
#contacts-panel {
|
||||||
|
width: 250px;
|
||||||
|
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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contacts-header {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 2px solid var(--copper);
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--brass);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contacts-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--brass) var(--leather);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
padding: 12px 15px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--copper);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact:hover {
|
||||||
|
background: rgba(212, 175, 55, 0.2);
|
||||||
|
border-color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact.selected {
|
||||||
|
background: rgba(212, 175, 55, 0.3);
|
||||||
|
border-color: var(--brass);
|
||||||
|
box-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact .status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--online-green);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 5px var(--online-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact .username {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact .unread {
|
||||||
|
background: var(--copper);
|
||||||
|
color: var(--parchment);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Panel */
|
||||||
|
#chat-container {
|
||||||
|
flex: 1;
|
||||||
background: rgba(43, 29, 14, 0.9);
|
background: rgba(43, 29, 14, 0.9);
|
||||||
border: 4px solid var(--copper);
|
border: 4px solid var(--copper);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -74,14 +165,37 @@
|
||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat-header {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 2px solid var(--copper);
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--brass);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#no-chat-selected {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--copper);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
#chat {
|
#chat {
|
||||||
height: 500px;
|
flex: 1;
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
border: 2px inset var(--copper);
|
border: 2px inset var(--copper);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--brass) var(--leather);
|
scrollbar-color: var(--brass) var(--leather);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat.active {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat::-webkit-scrollbar {
|
#chat::-webkit-scrollbar {
|
||||||
|
|
@ -107,16 +221,28 @@
|
||||||
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3);
|
box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.3);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message::after {
|
.message.sent {
|
||||||
content: '';
|
margin-left: auto;
|
||||||
position: absolute;
|
background: linear-gradient(to bottom, var(--brass), var(--copper));
|
||||||
bottom: -5px;
|
color: #2b1d0e;
|
||||||
left: 10px;
|
}
|
||||||
border-width: 5px 5px 0;
|
|
||||||
border-style: solid;
|
.message.received {
|
||||||
border-color: var(--copper) transparent;
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .sender {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--copper);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .sender {
|
||||||
|
color: #2b1d0e;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-msg {
|
.system-msg {
|
||||||
|
|
@ -128,14 +254,11 @@
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
max-width: 100%;
|
||||||
|
|
||||||
.system-msg::after {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls {
|
#controls {
|
||||||
display: flex;
|
display: none;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
|
|
@ -143,6 +266,10 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#controls.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
@ -179,62 +306,261 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>⚙ AetherChat ⚙</h1>
|
<h1>⚙ AetherChat ⚙</h1>
|
||||||
<div id="chat-container">
|
<div id="main-container">
|
||||||
<div id="chat"></div>
|
<!-- Contacts Sidebar -->
|
||||||
<div id="controls">
|
<div id="contacts-panel">
|
||||||
<input type="text" id="message" placeholder="Transmit payload..." autofocus>
|
<div id="contacts-header">⚙ Contacts</div>
|
||||||
<button onclick="sendMessage()">Engage</button>
|
<div id="contacts-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Area -->
|
||||||
|
<div id="chat-container">
|
||||||
|
<div id="chat-header">Select a contact to start chatting</div>
|
||||||
|
<div id="no-chat-selected">Click on a contact to begin transmission...</div>
|
||||||
|
<div id="chat"></div>
|
||||||
|
<div id="controls">
|
||||||
|
<input type="text" id="message" placeholder="Transmit payload..." autofocus>
|
||||||
|
<button onclick="sendMessage()">Engage</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const chatCheck = document.getElementById("chat");
|
const CLIENT_ID = "xLL8a23JBnBvEarjQUY6Jgd4zLnnqIi2y3oB6laM";
|
||||||
|
const AUTH_URL = "https://idm.flegr.me/application/o/authorize/";
|
||||||
|
const REDIRECT_URI = window.location.origin + window.location.pathname;
|
||||||
|
|
||||||
|
const contactsList = document.getElementById("contacts-list");
|
||||||
|
const chatDiv = document.getElementById("chat");
|
||||||
|
const chatHeader = document.getElementById("chat-header");
|
||||||
|
const noChatSelected = document.getElementById("no-chat-selected");
|
||||||
|
const controls = document.getElementById("controls");
|
||||||
const messageInput = document.getElementById("message");
|
const messageInput = document.getElementById("message");
|
||||||
|
|
||||||
// Connect to WebSocket
|
// State
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
let onlineUsers = [];
|
||||||
const ws = new WebSocket(`${proto}://${window.location.host}/ws`);
|
let selectedUser = null;
|
||||||
|
let messageHistory = {}; // { username: [messages...] }
|
||||||
|
let unreadCounts = {}; // { username: count }
|
||||||
|
let currentUsername = null; // Will be set from welcome message
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
// Handle OAuth Callback
|
||||||
const div = document.createElement("div");
|
async function handleAuth() {
|
||||||
div.textContent = event.data;
|
const params = new URLSearchParams(window.location.search);
|
||||||
div.className = "message";
|
const code = params.get("code");
|
||||||
|
|
||||||
if (event.data.startsWith("System:")) {
|
if (code) {
|
||||||
div.className += " system-msg";
|
// Exchange code for token
|
||||||
|
// NOTE: This assumes the Authentik client is "Public" and doesn't require a secret
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://idm.flegr.me/application/o/token/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code: code,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.access_token) {
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
} else {
|
||||||
|
console.error("Token exchange failed:", data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error exchanging code:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chatCheck.appendChild(div);
|
const storedToken = localStorage.getItem("access_token");
|
||||||
chatCheck.scrollTop = chatCheck.scrollHeight;
|
if (!storedToken) {
|
||||||
};
|
const url = `${AUTH_URL}?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=openid+profile+email`;
|
||||||
|
window.location.href = url;
|
||||||
ws.onopen = () => {
|
return null;
|
||||||
addSystemMessage("Connected to the Aether-Net.");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
addSystemMessage("Connection severed. Check steam pressure.");
|
|
||||||
};
|
|
||||||
|
|
||||||
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() {
|
|
||||||
const msg = messageInput.value;
|
|
||||||
if (msg) {
|
|
||||||
ws.send(msg);
|
|
||||||
messageInput.value = "";
|
|
||||||
}
|
}
|
||||||
|
return storedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageInput.addEventListener("keypress", (e) => {
|
(async () => {
|
||||||
if (e.key === "Enter") sendMessage();
|
const token = await handleAuth();
|
||||||
});
|
if (!token) return;
|
||||||
|
|
||||||
|
// Connect to WebSocket with token
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${window.location.host}/ws?token=${token}`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse message:", e);
|
||||||
|
// For debugging: show what we received
|
||||||
|
addSystemMessage("Error parsing: " + event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
addSystemMessage("Connected to the Aether-Net.");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
addSystemMessage("Connection severed. Check steam pressure.");
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "user_list":
|
||||||
|
onlineUsers = msg.users.filter(u => u !== currentUsername);
|
||||||
|
renderContacts();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_joined":
|
||||||
|
if (msg.username !== currentUsername && !onlineUsers.includes(msg.username)) {
|
||||||
|
onlineUsers.push(msg.username);
|
||||||
|
renderContacts();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "user_left":
|
||||||
|
onlineUsers = onlineUsers.filter(u => u !== msg.username);
|
||||||
|
renderContacts();
|
||||||
|
if (selectedUser === msg.username) {
|
||||||
|
addSystemMessage(`${msg.username} has disconnected.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "private_message":
|
||||||
|
handlePrivateMessage(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "system":
|
||||||
|
// Extract username from welcome message
|
||||||
|
if (msg.content.startsWith("Welcome, ")) {
|
||||||
|
currentUsername = msg.content.replace("Welcome, ", "").replace("!", "");
|
||||||
|
// Re-filter contacts now that we know our username
|
||||||
|
onlineUsers = onlineUsers.filter(u => u !== currentUsername);
|
||||||
|
renderContacts();
|
||||||
|
}
|
||||||
|
addSystemMessage(msg.content);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrivateMessage(msg) {
|
||||||
|
const otherUser = msg.from === currentUsername ? msg.to : msg.from;
|
||||||
|
const isSent = msg.from === currentUsername;
|
||||||
|
|
||||||
|
// Initialize history if needed
|
||||||
|
if (!messageHistory[otherUser]) {
|
||||||
|
messageHistory[otherUser] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store message
|
||||||
|
messageHistory[otherUser].push({
|
||||||
|
from: msg.from,
|
||||||
|
content: msg.content,
|
||||||
|
isSent: isSent
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this chat is open, render it
|
||||||
|
if (selectedUser === otherUser) {
|
||||||
|
renderChat();
|
||||||
|
} else if (!isSent) {
|
||||||
|
// Increment unread count for received messages
|
||||||
|
unreadCounts[otherUser] = (unreadCounts[otherUser] || 0) + 1;
|
||||||
|
renderContacts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContacts() {
|
||||||
|
contactsList.innerHTML = "";
|
||||||
|
onlineUsers.forEach(username => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "contact" + (selectedUser === username ? " selected" : "");
|
||||||
|
div.onclick = () => selectUser(username);
|
||||||
|
|
||||||
|
const statusDot = document.createElement("span");
|
||||||
|
statusDot.className = "status-dot";
|
||||||
|
|
||||||
|
const nameSpan = document.createElement("span");
|
||||||
|
nameSpan.className = "username";
|
||||||
|
nameSpan.textContent = username;
|
||||||
|
|
||||||
|
div.appendChild(statusDot);
|
||||||
|
div.appendChild(nameSpan);
|
||||||
|
|
||||||
|
// Show unread badge
|
||||||
|
if (unreadCounts[username] && unreadCounts[username] > 0) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "unread";
|
||||||
|
badge.textContent = unreadCounts[username];
|
||||||
|
div.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(username) {
|
||||||
|
selectedUser = username;
|
||||||
|
unreadCounts[username] = 0; // Clear unread
|
||||||
|
chatHeader.textContent = `Chat with ${username}`;
|
||||||
|
noChatSelected.style.display = "none";
|
||||||
|
chatDiv.classList.add("active");
|
||||||
|
controls.classList.add("active");
|
||||||
|
renderContacts();
|
||||||
|
renderChat();
|
||||||
|
messageInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
chatDiv.innerHTML = "";
|
||||||
|
const messages = messageHistory[selectedUser] || [];
|
||||||
|
messages.forEach(msg => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "message " + (msg.isSent ? "sent" : "received");
|
||||||
|
|
||||||
|
const sender = document.createElement("div");
|
||||||
|
sender.className = "sender";
|
||||||
|
sender.textContent = msg.from;
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.textContent = msg.content;
|
||||||
|
|
||||||
|
div.appendChild(sender);
|
||||||
|
div.appendChild(content);
|
||||||
|
chatDiv.appendChild(div);
|
||||||
|
});
|
||||||
|
chatDiv.scrollTop = chatDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSystemMessage(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
div.className = "message system-msg";
|
||||||
|
chatDiv.appendChild(div);
|
||||||
|
chatDiv.scrollTop = chatDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const content = messageInput.value.trim();
|
||||||
|
if (content && selectedUser) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
to: selectedUser,
|
||||||
|
content: content
|
||||||
|
}));
|
||||||
|
messageInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageInput.addEventListener("keypress", (e) => {
|
||||||
|
if (e.key === "Enter") sendMessage();
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
328
src/main.rs
328
src/main.rs
|
|
@ -1,211 +1,243 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Router,
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
||||||
Query, State,
|
Query, State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
},
|
},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use futures::{sink::SinkExt, stream::StreamExt};
|
use futures::{sink::SinkExt, stream::StreamExt};
|
||||||
use oauth2::{
|
use serde::{Deserialize, Serialize};
|
||||||
basic::BasicClient,AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenResponse,
|
use std::{collections::HashMap, sync::Arc};
|
||||||
TokenUrl,
|
use tokio::sync::{RwLock, mpsc};
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tower_sessions::{cookie::SameSite, Expiry, MemoryStore, Session, SessionManagerLayer};
|
|
||||||
|
|
||||||
mod user;
|
mod user;
|
||||||
use user::User;
|
use user::User;
|
||||||
|
|
||||||
// Define application state
|
// WebSocket message types for client-server communication
|
||||||
struct AppState {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
tx: broadcast::Sender<String>,
|
#[serde(tag = "type")]
|
||||||
oauth_client: BasicClient,
|
enum WsMessage {
|
||||||
|
#[serde(rename = "user_list")]
|
||||||
|
UserList { users: Vec<String> },
|
||||||
|
#[serde(rename = "private_message")]
|
||||||
|
PrivateMessage {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "user_joined")]
|
||||||
|
UserJoined { username: String },
|
||||||
|
#[serde(rename = "system")]
|
||||||
|
System { content: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
const AFTER_LOGIN_URL: &str = "/";
|
// Per-user connection channel
|
||||||
|
type UserTx = mpsc::UnboundedSender<String>;
|
||||||
|
|
||||||
|
// Shared state for tracking online users and their connections
|
||||||
|
struct ChatState {
|
||||||
|
// Map of username -> sender channel
|
||||||
|
connections: RwLock<HashMap<String, UserTx>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connections: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user(&self, username: String, tx: UserTx) {
|
||||||
|
let mut conns = self.connections.write().await;
|
||||||
|
conns.insert(username, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user(&self, username: &str) {
|
||||||
|
let mut conns = self.connections.write().await;
|
||||||
|
conns.remove(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_online_users(&self) -> Vec<String> {
|
||||||
|
let conns = self.connections.read().await;
|
||||||
|
conns.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_to_user(&self, username: &str, message: &str) -> bool {
|
||||||
|
let conns = self.connections.read().await;
|
||||||
|
if let Some(tx) = conns.get(username) {
|
||||||
|
tx.send(message.to_string()).is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast_except(&self, message: &str, exclude: &str) {
|
||||||
|
let conns = self.connections.read().await;
|
||||||
|
for (username, tx) in conns.iter() {
|
||||||
|
if username != exclude {
|
||||||
|
let _ = tx.send(message.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application state
|
||||||
|
struct AppState {
|
||||||
|
chat: ChatState,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
// Create a broadcast channel
|
|
||||||
let (tx, _rx) = broadcast::channel(100);
|
|
||||||
|
|
||||||
// OAuth configuration
|
|
||||||
let client_id = dotenvy::var("CLIENT_ID").unwrap();
|
|
||||||
let client_secret = dotenvy::var("CLIENT_SECRET").unwrap();
|
|
||||||
let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string());
|
|
||||||
let host = dotenvy::var("HOST").unwrap_or_else(|_| "http://localhost:".to_string() + &port);
|
|
||||||
// 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(host + "/auth/callback")
|
|
||||||
.expect("Invalid redirect URL"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
tx,
|
chat: ChatState::new(),
|
||||||
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
|
// Run the app
|
||||||
let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string());
|
let port = dotenvy::var("PORT").unwrap_or_else(|_| "3001".to_string());
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port).await.unwrap();
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:".to_string() + &port)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
println!("Chat server listening on http://0.0.0.0:{}", port);
|
println!("Chat server listening on http://0.0.0.0:{}", port);
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
||||||
async fn index(session: Session) -> impl IntoResponse {
|
async fn index() -> impl IntoResponse {
|
||||||
if let Some(_user) = session.get::<User>("user").await.unwrap() {
|
Html(include_str!("../index.html"))
|
||||||
return Html(include_str!("../index.html")).into_response();
|
|
||||||
}
|
|
||||||
Redirect::to("/login").into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
#[derive(Deserialize)]
|
||||||
let (auth_url, _csrf_token) = state
|
struct WsParams {
|
||||||
.oauth_client
|
token: String,
|
||||||
.authorize_url(oauth2::CsrfToken::new_random)
|
|
||||||
.url();
|
|
||||||
|
|
||||||
Redirect::to(auth_url.as_str())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn logout(session: Session) -> impl IntoResponse {
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
session.delete().await.unwrap();
|
struct Claims {
|
||||||
Redirect::to("/login")
|
preferred_username: String,
|
||||||
}
|
// Add other fields as needed
|
||||||
|
|
||||||
#[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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn websocket_handler(
|
async fn websocket_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
|
Query(params): Query<WsParams>,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
session: Session,
|
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if let Some(user) = session.get::<User>("user").await.unwrap() {
|
// For now, we'll verify the token by calling the userinfo endpoint.
|
||||||
return ws.on_upgrade(move |socket| websocket(socket, state, user));
|
// In a production app, you should verify the JWT signature locally using JWKS.
|
||||||
|
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(¶ms.token)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match user_info_resp {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let user: User = match resp.json().await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
};
|
||||||
|
ws.on_upgrade(move |socket| handle_websocket(socket, state, user))
|
||||||
|
}
|
||||||
|
_ => StatusCode::UNAUTHORIZED.into_response(),
|
||||||
}
|
}
|
||||||
StatusCode::UNAUTHORIZED.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket handler
|
// WebSocket connection handler
|
||||||
async fn websocket(stream: WebSocket, state: Arc<AppState>, user: User) {
|
async fn handle_websocket(stream: WebSocket, state: Arc<AppState>, user: User) {
|
||||||
let (mut sender, mut receiver) = stream.split();
|
let (mut ws_sender, mut ws_receiver) = stream.split();
|
||||||
let mut rx = state.tx.subscribe();
|
let username = user.login.clone();
|
||||||
|
|
||||||
|
// Create channel for this user
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||||
|
|
||||||
|
// Add user to connections
|
||||||
|
state.chat.add_user(username.clone(), tx).await;
|
||||||
|
println!("User connected: {}", username);
|
||||||
|
|
||||||
|
// Send current user list to the newly connected user
|
||||||
|
let users = state.chat.get_online_users().await;
|
||||||
|
println!("Sending user list to {}: {:?}", username, users);
|
||||||
|
let user_list_msg = serde_json::to_string(&WsMessage::UserList { users }).unwrap();
|
||||||
|
let _ = ws_sender.send(Message::Text(user_list_msg.into())).await;
|
||||||
|
|
||||||
// Send welcome message
|
// Send welcome message
|
||||||
let msg = format!("System: Welcome, {}!", user.login);
|
let welcome = serde_json::to_string(&WsMessage::System {
|
||||||
let _ = sender.send(Message::Text(msg.into())).await;
|
content: format!("Welcome, {}!", username),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let _ = ws_sender.send(Message::Text(welcome.into())).await;
|
||||||
|
|
||||||
// Send task
|
// Task to forward messages from channel to WebSocket
|
||||||
let mut send_task = tokio::spawn(async move {
|
let send_task = tokio::spawn(async move {
|
||||||
while let Ok(msg) = rx.recv().await {
|
while let Some(msg) = rx.recv().await {
|
||||||
if sender.send(Message::Text(msg.into())).await.is_err() {
|
if ws_sender.send(Message::Text(msg.into())).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Receive task
|
// Broadcast user joined to everyone else (after send_task is spawned so others receive it)
|
||||||
let tx = state.tx.clone();
|
let join_msg = serde_json::to_string(&WsMessage::UserJoined {
|
||||||
let username = user.login.clone();
|
username: username.clone(),
|
||||||
let mut recv_task = tokio::spawn(async move {
|
})
|
||||||
while let Some(Ok(Message::Text(text))) = receiver.next().await {
|
.unwrap();
|
||||||
// Prepend username
|
state.chat.broadcast_except(&join_msg, &username).await;
|
||||||
let msg = format!("{}: {}", username, text);
|
|
||||||
let _ = tx.send(msg);
|
// Task to receive messages from WebSocket and route them
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let username_clone = username.clone();
|
||||||
|
let recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(Message::Text(text))) = ws_receiver.next().await {
|
||||||
|
// Parse incoming message
|
||||||
|
if let Ok(msg) = serde_json::from_str::<ClientMessage>(&text) {
|
||||||
|
// Create private message
|
||||||
|
let private_msg = serde_json::to_string(&WsMessage::PrivateMessage {
|
||||||
|
from: username_clone.clone(),
|
||||||
|
to: msg.to.clone(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Send to recipient
|
||||||
|
state_clone.chat.send_to_user(&msg.to, &private_msg).await;
|
||||||
|
|
||||||
|
// Also send back to sender (for their own chat view)
|
||||||
|
state_clone
|
||||||
|
.chat
|
||||||
|
.send_to_user(&username_clone, &private_msg)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for either task to complete
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = (&mut send_task) => recv_task.abort(),
|
_ = send_task => {},
|
||||||
_ = (&mut recv_task) => send_task.abort(),
|
_ = recv_task => {},
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// User disconnected - clean up
|
||||||
|
state.chat.remove_user(&username).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message structure from client
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ClientMessage {
|
||||||
|
to: String,
|
||||||
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue