Compare commits
4 Commits
f6d2999b96
...
d5909e09fd
| Author | SHA1 | Date | |
|---|---|---|---|
| d5909e09fd | |||
| 91ff2e00c4 | |||
| 07857f1d0a | |||
| 954648c7a2 |
@@ -0,0 +1,15 @@
|
|||||||
|
// Folder-specific settings
|
||||||
|
//
|
||||||
|
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||||
|
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||||
|
{
|
||||||
|
"lsp": {
|
||||||
|
"rust-analyzer": {
|
||||||
|
"initialization_options": {
|
||||||
|
"check": {
|
||||||
|
"command": "clippy" // rust-analyzer.check.command (default: "check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
|
dotenv = "0.15.0"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
image = "0.25.8"
|
image = "0.25.8"
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ pub async fn signup(
|
|||||||
token_id.use_token(&mut db).await?;
|
token_id.use_token(&mut db).await?;
|
||||||
|
|
||||||
println!("phase 5");
|
println!("phase 5");
|
||||||
return Ok(Redirect::to("/chat"));
|
Ok(Redirect::to("/chat"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/login")]
|
#[get("/login")]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use rocket::{
|
|||||||
http::Status,
|
http::Status,
|
||||||
outcome::{Outcome, try_outcome},
|
outcome::{Outcome, try_outcome},
|
||||||
request::{self, FromRequest},
|
request::{self, FromRequest},
|
||||||
response::status::{self, BadRequest},
|
response::status::{self},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
@@ -50,7 +50,7 @@ pub async fn confirm_totp(
|
|||||||
println!("valid");
|
println!("valid");
|
||||||
|
|
||||||
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes()).unwrap();
|
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes()).unwrap();
|
||||||
if !totp.check_current(&format!("{}", form.code)).unwrap() {
|
if !totp.check_current(&form.code.to_string()).unwrap() {
|
||||||
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ pub async fn confirm_totp(
|
|||||||
|
|
||||||
println!("enabled");
|
println!("enabled");
|
||||||
|
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/totp.jpg")]
|
#[get("/totp.jpg")]
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ pub struct UploadResponse {
|
|||||||
#[post("/profile/<user_id>/upload", data = "<file>")]
|
#[post("/profile/<user_id>/upload", data = "<file>")]
|
||||||
pub async fn upload_profile_pic(
|
pub async fn upload_profile_pic(
|
||||||
user_id: usize,
|
user_id: usize,
|
||||||
mut file: Form<TempFile<'_>>,
|
file: Form<TempFile<'_>>,
|
||||||
) -> Result<Json<UploadResponse>, Status> {
|
) -> Result<Json<UploadResponse>, Status> {
|
||||||
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024;
|
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024;
|
||||||
if file.len() > MAX_FILE_SIZE {
|
if file.len() > MAX_FILE_SIZE {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
// src/llm.rs
|
// src/llm.rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::messages::ChatMsg;
|
use crate::messenger::ChatMsg;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct LlmRequest {
|
struct LlmRequest {
|
||||||
@@ -32,7 +32,7 @@ impl LlmWorker {
|
|||||||
model: "gpt-oss-20b".into(), // whatever model you run locally
|
model: "gpt-oss-20b".into(), // whatever model you run locally
|
||||||
messages: vec![Message {
|
messages: vec![Message {
|
||||||
role: "user".into(),
|
role: "user".into(),
|
||||||
content: message.text.clone().into(),
|
content: message.text.clone(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+16
-76
@@ -2,17 +2,15 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
|
|
||||||
use redis::cmd;
|
|
||||||
use rocket::fs::{FileServer, NamedFile};
|
use rocket::fs::{FileServer, NamedFile};
|
||||||
use rocket::http::Method;
|
use rocket::http::Method;
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{Build, Rocket};
|
use rocket::{Build, Rocket};
|
||||||
use rocket_cors::{AllowedOrigins, CorsOptions};
|
use rocket_cors::{AllowedOrigins, CorsOptions};
|
||||||
use rocket_db_pools::{Connection, Database};
|
use rocket_db_pools::Database;
|
||||||
use rocket_dyn_templates::Template;
|
use rocket_dyn_templates::Template;
|
||||||
use std::sync::Arc;
|
use std::env;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::auth::Session;
|
|
||||||
use crate::db::{Postgres, Redis};
|
use crate::db::{Postgres, Redis};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
@@ -20,33 +18,18 @@ pub mod cdn;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod messages;
|
pub mod messenger;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
#[get("/users", rank = 2)]
|
static LMSTUDIO_URL: LazyLock<String> =
|
||||||
async fn users(_ag: Session, mut db: Connection<Postgres>) -> Json<Vec<i32>> {
|
LazyLock::new(|| env::var("LMSTUDIO_URL").expect("Ensure LMSTUDIO_URL is set!"));
|
||||||
sqlx::query!("SELECT id FROM users")
|
|
||||||
.fetch_all(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| Vec::new())
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| row.id)
|
|
||||||
.collect::<Vec<i32>>()
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/users/<id>", rank = 1)]
|
|
||||||
async fn display_name(
|
|
||||||
id: usize,
|
|
||||||
_ag: Session,
|
|
||||||
mut pgsql_conn: Connection<Postgres>,
|
|
||||||
mut redis_conn: Connection<Redis>,
|
|
||||||
) -> String {
|
|
||||||
UserCache::username(id, &mut redis_conn, &mut pgsql_conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[launch]
|
#[launch]
|
||||||
fn rocket() -> Rocket<Build> {
|
fn rocket() -> Rocket<Build> {
|
||||||
let chat = Arc::new(crate::messages::ChatBroadcaster::new(32));
|
// make sure the env is loaded
|
||||||
|
dotenv::dotenv().expect("Failed to load env! aborting launch!");
|
||||||
|
|
||||||
|
let chat = Arc::new(crate::messenger::ChatBroadcaster::new(32));
|
||||||
|
|
||||||
let cors = CorsOptions::default()
|
let cors = CorsOptions::default()
|
||||||
.allowed_origins(AllowedOrigins::all())
|
.allowed_origins(AllowedOrigins::all())
|
||||||
@@ -70,7 +53,7 @@ fn rocket() -> Rocket<Build> {
|
|||||||
"/",
|
"/",
|
||||||
routes![
|
routes![
|
||||||
favicon,
|
favicon,
|
||||||
messages::chat_page,
|
messenger::chat_page,
|
||||||
auth::signup_page,
|
auth::signup_page,
|
||||||
auth::login_page,
|
auth::login_page,
|
||||||
auth::mfa_page,
|
auth::mfa_page,
|
||||||
@@ -81,11 +64,10 @@ fn rocket() -> Rocket<Build> {
|
|||||||
"/api",
|
"/api",
|
||||||
routes![
|
routes![
|
||||||
cdn::upload_profile_pic,
|
cdn::upload_profile_pic,
|
||||||
messages::get_messages,
|
messenger::post_message,
|
||||||
messages::post_message,
|
messenger::event_stream,
|
||||||
messages::event_stream,
|
user::users,
|
||||||
users,
|
user::display_name,
|
||||||
display_name,
|
|
||||||
auth::signup,
|
auth::signup,
|
||||||
auth::login,
|
auth::login,
|
||||||
auth::get_totp,
|
auth::get_totp,
|
||||||
@@ -107,45 +89,3 @@ fn rocket() -> Rocket<Build> {
|
|||||||
async fn favicon() -> NamedFile {
|
async fn favicon() -> NamedFile {
|
||||||
NamedFile::open("static/favicon.ico").await.unwrap()
|
NamedFile::open("static/favicon.ico").await.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UserCache {}
|
|
||||||
|
|
||||||
impl UserCache {
|
|
||||||
pub async fn username(
|
|
||||||
id: usize,
|
|
||||||
redis_conn: &mut Connection<Redis>,
|
|
||||||
pgsql_conn: &mut Connection<Postgres>,
|
|
||||||
) -> String {
|
|
||||||
if let Ok(val) = cmd("GET")
|
|
||||||
.arg(&[format!("users:{id}")])
|
|
||||||
.query_async(&mut **redis_conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(v) = sqlx::query!("SELECT username FROM users WHERE id = $1", id as i32)
|
|
||||||
.fetch_one(&mut ***pgsql_conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let username = v.username;
|
|
||||||
Self::insert(id, &username, redis_conn).await;
|
|
||||||
username
|
|
||||||
} else {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn insert(id: usize, username: &str, conn: &mut Connection<Redis>) {
|
|
||||||
cmd("SET")
|
|
||||||
.arg(&[
|
|
||||||
format!("users:{id}"),
|
|
||||||
username.to_string(),
|
|
||||||
"EX".to_string(),
|
|
||||||
"1800".to_string(),
|
|
||||||
])
|
|
||||||
.query_async(&mut **conn)
|
|
||||||
.await
|
|
||||||
.expect("failed to insert key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use rocket_db_pools::Connection;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{Postgres, Redis},
|
db::{Postgres, Redis},
|
||||||
messages::ChatMsg,
|
messenger::ChatMsg,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to cache message in Redis
|
// Helper function to cache message in Redis
|
||||||
@@ -51,8 +51,8 @@ pub async fn initialise(
|
|||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let key = format!("messages:{}", channel_id);
|
let key = format!("messages:{}", channel_id);
|
||||||
|
|
||||||
let length: usize = cache.llen(&key).await?;
|
// less than 100 messages in cache?
|
||||||
if length < 100 {
|
if cache.llen::<_, i32>(&key).await? < 100 {
|
||||||
// Fetch from Postgres
|
// Fetch from Postgres
|
||||||
let messages = sqlx::query!(
|
let messages = sqlx::query!(
|
||||||
"SELECT u.username, u.display_name, u.id, m.content, m.created_at
|
"SELECT u.username, u.display_name, u.id, m.content, m.created_at
|
||||||
@@ -68,14 +68,12 @@ pub async fn initialise(
|
|||||||
|
|
||||||
// Populate cache (in reverse order so oldest is at the end)
|
// Populate cache (in reverse order so oldest is at the end)
|
||||||
for msg in messages.into_iter().rev() {
|
for msg in messages.into_iter().rev() {
|
||||||
let chat_msg = ChatMsg {
|
let msg_json = serde_json::to_string(&ChatMsg {
|
||||||
display_name: Some(msg.display_name.unwrap_or(msg.username)),
|
display_name: Some(msg.display_name.unwrap_or(msg.username)),
|
||||||
user_id: msg.id as usize,
|
user_id: msg.id as usize,
|
||||||
text: msg.content,
|
text: msg.content,
|
||||||
timestamp: (msg.created_at.unwrap().unix_timestamp_nanos() / 1_000_000) as usize,
|
timestamp: (msg.created_at.unwrap().unix_timestamp_nanos() / 1_000_000) as usize,
|
||||||
};
|
})?;
|
||||||
|
|
||||||
let msg_json = serde_json::to_string(&chat_msg)?;
|
|
||||||
|
|
||||||
cache.lpush::<_, _, ()>(&key, &msg_json).await?;
|
cache.lpush::<_, _, ()>(&key, &msg_json).await?;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use redis::{AsyncCommands, cmd};
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Shutdown,
|
Shutdown,
|
||||||
response::stream::{Event, EventStream},
|
response::stream::{Event, EventStream},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
time::OffsetDateTime,
|
time::OffsetDateTime,
|
||||||
};
|
};
|
||||||
use rocket_cors::CorsOptions;
|
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use rocket_dyn_templates::{Template, context};
|
use rocket_dyn_templates::{Template, context};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -18,25 +16,39 @@ use crate::{
|
|||||||
auth::Session,
|
auth::Session,
|
||||||
db::{Postgres, Redis},
|
db::{Postgres, Redis},
|
||||||
llm::LlmWorker,
|
llm::LlmWorker,
|
||||||
|
messenger,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// ---------- shared broadcaster ----------
|
/// ---------- shared broadcaster ----------
|
||||||
pub struct ChatBroadcaster {
|
pub struct ChatBroadcaster {
|
||||||
sender: broadcast::Sender<ChatMsg>,
|
buffer_size: usize,
|
||||||
|
senders: std::sync::Mutex<std::collections::HashMap<i32, broadcast::Sender<ChatMsg>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatBroadcaster {
|
impl ChatBroadcaster {
|
||||||
pub fn new(buffer_size: usize) -> Self {
|
pub fn new(buffer_size: usize) -> Self {
|
||||||
let (sender, _rx) = broadcast::channel::<ChatMsg>(buffer_size);
|
Self {
|
||||||
Self { sender }
|
buffer_size,
|
||||||
|
senders: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish(&self, msg: ChatMsg) {
|
/// Publish a message to the specified channel.
|
||||||
let _ = self.sender.send(msg);
|
pub async fn publish(&self, channel_id: i32, msg: ChatMsg) {
|
||||||
|
let mut map = self.senders.lock().unwrap();
|
||||||
|
let sender = map
|
||||||
|
.entry(channel_id)
|
||||||
|
.or_insert_with(|| broadcast::channel::<ChatMsg>(self.buffer_size).0);
|
||||||
|
let _ = sender.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<ChatMsg> {
|
/// Subscribe to the specified channel.
|
||||||
self.sender.subscribe()
|
pub fn subscribe(&self, channel_id: i32) -> broadcast::Receiver<ChatMsg> {
|
||||||
|
let mut map = self.senders.lock().unwrap();
|
||||||
|
let sender = map
|
||||||
|
.entry(channel_id)
|
||||||
|
.or_insert_with(|| broadcast::channel::<ChatMsg>(self.buffer_size).0);
|
||||||
|
sender.subscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,18 +61,15 @@ pub struct ChatMsg {
|
|||||||
pub timestamp: usize,
|
pub timestamp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/chat", format = "json", data = "<msg>")]
|
#[post("/chat/<channel_id>", format = "json", data = "<msg>")]
|
||||||
pub async fn post_message(
|
pub async fn post_message(
|
||||||
mut msg: Json<ChatMsg>,
|
mut msg: Json<ChatMsg>,
|
||||||
chat: &rocket::State<Arc<ChatBroadcaster>>,
|
chat: &rocket::State<Arc<ChatBroadcaster>>,
|
||||||
mut postgres: Connection<Postgres>,
|
mut postgres: Connection<Postgres>,
|
||||||
mut cache: Connection<Redis>,
|
mut cache: Option<Connection<Redis>>,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
channel_id: i32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
const CHANNEL_ID: i32 = 1;
|
|
||||||
let channel_id = CHANNEL_ID;
|
|
||||||
const LMSTUDIO_URI: &'static str = "http://127.0.0.1:1234/v1/chat/completions";
|
|
||||||
|
|
||||||
let chat = chat.inner().clone();
|
let chat = chat.inner().clone();
|
||||||
|
|
||||||
let display_name = sqlx::query!(
|
let display_name = sqlx::query!(
|
||||||
@@ -74,11 +83,11 @@ pub async fn post_message(
|
|||||||
|
|
||||||
msg.user_id = session.user_id;
|
msg.user_id = session.user_id;
|
||||||
msg.display_name = Some(display_name);
|
msg.display_name = Some(display_name);
|
||||||
chat.publish(msg.clone().into_inner()).await;
|
chat.publish(channel_id, msg.clone().into_inner()).await;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES ($1, $2, $3, $4)",
|
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES ($1, $2, $3, $4)",
|
||||||
CHANNEL_ID,
|
channel_id,
|
||||||
msg.user_id as i32,
|
msg.user_id as i32,
|
||||||
msg.text,
|
msg.text,
|
||||||
OffsetDateTime::from_unix_timestamp_nanos(msg.timestamp as i128 * 1_000_000).unwrap()
|
OffsetDateTime::from_unix_timestamp_nanos(msg.timestamp as i128 * 1_000_000).unwrap()
|
||||||
@@ -87,22 +96,30 @@ pub async fn post_message(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| "Failed".to_string())?;
|
.map_err(|_| "Failed".to_string())?;
|
||||||
|
|
||||||
super::cache::insert(&mut cache, channel_id, &msg)
|
if let Some(ref mut cache) = cache {
|
||||||
|
messenger::cache::insert(cache, channel_id, &msg)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "Redis cache failed".to_string())?;
|
.map_err(|_| "Redis cache failed".to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
// get response
|
// get response
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let response = LlmWorker::new(LMSTUDIO_URI.to_string()).query(&msg).await;
|
let response = LlmWorker::new(crate::LMSTUDIO_URL.to_string())
|
||||||
|
.query(&msg)
|
||||||
|
.await;
|
||||||
|
|
||||||
if let Ok(reply) = response {
|
if let Ok(reply) = response {
|
||||||
chat.publish(reply.clone()).await;
|
chat.publish(channel_id, reply.clone()).await;
|
||||||
super::cache::insert(&mut cache, CHANNEL_ID, &reply)
|
|
||||||
|
if let Some(ref mut cache) = cache {
|
||||||
|
messenger::cache::insert(cache, channel_id, &reply)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES ($1, $2, $3, $4)",
|
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES ($1, $2, $3, $4)",
|
||||||
CHANNEL_ID,
|
channel_id,
|
||||||
reply.user_id as i32,
|
reply.user_id as i32,
|
||||||
reply.text,
|
reply.text,
|
||||||
OffsetDateTime::from_unix_timestamp_nanos(reply.timestamp as i128 * 1_000_000).unwrap()
|
OffsetDateTime::from_unix_timestamp_nanos(reply.timestamp as i128 * 1_000_000).unwrap()
|
||||||
@@ -117,26 +134,22 @@ pub async fn post_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/messages")]
|
|
||||||
pub async fn get_messages(
|
pub async fn get_messages(
|
||||||
mut db: Connection<Postgres>,
|
mut db: Connection<Postgres>,
|
||||||
mut redis: Connection<Redis>,
|
mut redis: Connection<Redis>,
|
||||||
_session: Session,
|
channel_id: i32,
|
||||||
) -> Json<Vec<ChatMsg>> {
|
) -> Json<Vec<ChatMsg>> {
|
||||||
const CHANNEL_ID: i32 = 1;
|
if let Ok(messages) = messenger::cache::get(&mut redis, channel_id).await
|
||||||
let channel_id = CHANNEL_ID;
|
|
||||||
|
|
||||||
if let Ok(messages) = super::cache::get(&mut redis, channel_id).await
|
|
||||||
&& !messages.is_empty()
|
&& !messages.is_empty()
|
||||||
{
|
{
|
||||||
return Json(messages);
|
return Json(messages);
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(x) = super::cache::initialise(&mut redis, &mut db, channel_id).await {
|
if let Err(x) = messenger::cache::initialise(&mut redis, &mut db, channel_id).await {
|
||||||
eprintln!("WARN: {x:?}");
|
eprintln!("WARN: {x:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(messages) = super::cache::get(&mut redis, channel_id).await
|
if let Ok(messages) = messenger::cache::get(&mut redis, channel_id).await
|
||||||
&& !messages.is_empty()
|
&& !messages.is_empty()
|
||||||
{
|
{
|
||||||
return Json(messages);
|
return Json(messages);
|
||||||
@@ -165,19 +178,20 @@ pub async fn get_messages(
|
|||||||
Json(res)
|
Json(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/events")]
|
#[get("/events/<channel_id>")]
|
||||||
pub async fn event_stream(
|
pub async fn event_stream(
|
||||||
chat: &rocket::State<Arc<ChatBroadcaster>>,
|
chat: &rocket::State<Arc<ChatBroadcaster>>,
|
||||||
postgres: Connection<Postgres>,
|
postgres: Connection<Postgres>,
|
||||||
cache: Connection<Redis>,
|
cache: Connection<Redis>,
|
||||||
ag: Session,
|
_session: Session,
|
||||||
mut shutdown: Shutdown,
|
mut shutdown: Shutdown,
|
||||||
|
channel_id: i32,
|
||||||
) -> EventStream![] {
|
) -> EventStream![] {
|
||||||
let mut rx = chat.subscribe();
|
let mut rx = chat.subscribe(channel_id);
|
||||||
|
|
||||||
EventStream! {
|
EventStream! {
|
||||||
// Initialize the stream with the last 100 messages
|
// Initialize the stream with the last 100 messages
|
||||||
for msg in get_messages(postgres, cache, ag).await.0 {
|
for msg in get_messages(postgres, cache, channel_id).await.0 {
|
||||||
yield Event::json(&msg);
|
yield Event::json(&msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +216,3 @@ pub async fn event_stream(
|
|||||||
pub async fn chat_page(session: Session) -> Template {
|
pub async fn chat_page(session: Session) -> Template {
|
||||||
Template::render("chat", context!(user_id: session.user_id))
|
Template::render("chat", context!(user_id: session.user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/chatpreview")]
|
|
||||||
pub async fn chat_page_preview(session: Session) -> Template {
|
|
||||||
Template::render("chatpreview", context!(user_id: session.user_id))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use redis::AsyncCommands;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::Session,
|
||||||
|
db::{Postgres, Redis},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[get("/users", rank = 2)]
|
||||||
|
pub async fn users(_ag: Session, mut db: Connection<Postgres>) -> Json<Vec<i32>> {
|
||||||
|
sqlx::query!("SELECT id FROM users")
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Vec::new())
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.id)
|
||||||
|
.collect::<Vec<i32>>()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/users/<id>", rank = 1)]
|
||||||
|
pub async fn display_name(
|
||||||
|
id: usize,
|
||||||
|
_ag: Session,
|
||||||
|
mut pgsql_conn: Connection<Postgres>,
|
||||||
|
mut redis_conn: Connection<Redis>,
|
||||||
|
) -> String {
|
||||||
|
UserCache::username(id, &mut redis_conn, &mut pgsql_conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserCache {}
|
||||||
|
|
||||||
|
impl UserCache {
|
||||||
|
pub async fn username(
|
||||||
|
id: usize,
|
||||||
|
redis_conn: &mut Connection<Redis>,
|
||||||
|
pgsql_conn: &mut Connection<Postgres>,
|
||||||
|
) -> String {
|
||||||
|
if let Ok(val) = redis_conn.get(format!("users:{id}")).await {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(v) = sqlx::query!("SELECT username FROM users WHERE id = $1", id as i32)
|
||||||
|
.fetch_one(&mut ***pgsql_conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let username = v.username;
|
||||||
|
Self::insert(id, &username, redis_conn).await;
|
||||||
|
username
|
||||||
|
} else {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(id: usize, username: &str, conn: &mut Connection<Redis>) {
|
||||||
|
conn.set_ex::<_, _, ()>(format!("users:{id}"), username.to_string(), 1800)
|
||||||
|
.await
|
||||||
|
.expect("failed to insert key");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,12 +30,18 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Container */
|
||||||
.chat-container {
|
.chat-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
min-width: 100vw;
|
|
||||||
margin: 0 0;
|
|
||||||
background: #121212;
|
background: #121212;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -46,12 +52,16 @@ body {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border-bottom: 1px solid #252525;
|
border-bottom: 1px solid #252525;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-title {
|
.chat-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -773,3 +783,153 @@ body {
|
|||||||
.checkbox-group a:hover {
|
.checkbox-group a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar Styles */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 400px;
|
||||||
|
background: #0f0f0f;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #252525;
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
margin-left 0.3s ease,
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.hidden {
|
||||||
|
margin-left: -240px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
background: rgba(106, 90, 205, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:active {
|
||||||
|
background: #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #252525;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #b0b0b0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(30, 30, 30, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item:hover {
|
||||||
|
background: rgba(30, 30, 30, 0.6);
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item.active {
|
||||||
|
background: rgba(106, 90, 205, 0.15);
|
||||||
|
border-color: rgba(106, 90, 205, 0.3);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(106, 90, 205, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item:hover .channel-icon {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item.active .channel-icon {
|
||||||
|
color: #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for sidebar */
|
||||||
|
.channels-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list::-webkit-scrollbar-track {
|
||||||
|
background: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #252525;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(30, 30, 30, 0.6);
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #b0b0b0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
background: rgba(106, 90, 205, 0.2);
|
||||||
|
border-color: rgba(106, 90, 205, 0.3);
|
||||||
|
color: #e0e0e0;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,44 +5,73 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Discord Clone - Group Chat</title>
|
<title>Discord Clone - Group Chat</title>
|
||||||
<link rel="stylesheet" href="static/css/index.css"/>
|
<link rel="stylesheet" href="static/css/index.css"/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="resize-handle"></div>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
Channels
|
||||||
|
</div>
|
||||||
|
<div class="channels-list">
|
||||||
|
<div class="channel-item active" data-channel-id="1">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="2">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 2</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="3">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 3</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="4">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 4</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="5">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 5</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="6">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 6</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="7">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 7</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="8">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 8</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="9">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 9</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-item" data-channel-id="10">
|
||||||
|
<span class="channel-icon">#</span>
|
||||||
|
<span class="channel-name">Channel 10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Container -->
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<!--<div class="chat-container" style="background-image: url('cdn/background.png'); backdrop-filter: blur(10px); background-size: cover; background-position: center; background-repeat: no-repeat;">-->
|
|
||||||
<!-- Chat Header -->
|
<!-- Chat Header -->
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
|
<button class="sidebar-toggle" id="sidebarToggle">☰</button>
|
||||||
<div class="chat-title">
|
<div class="chat-title">
|
||||||
<img class="user-avatar" src="cdn/profile/0"></img>
|
<img class="user-avatar" src="cdn/profile/0"></img>
|
||||||
<h1>Wish.com Discord frfr</h1>
|
<h1>Wish.com Discord frfr</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Location Notification Bubble -->
|
|
||||||
<div class="notification-container">
|
|
||||||
<!--<div class="live-location-bubble" id="locationBubble">
|
|
||||||
<div class="map-container">
|
|
||||||
<img src="cdn/map.png" alt="Map" />
|
|
||||||
</div>
|
|
||||||
<div class="location-content">
|
|
||||||
<div class="location-icon">
|
|
||||||
<img src="cdn/icons/location.svg" alt="Location"></img>
|
|
||||||
</div>
|
|
||||||
<button class="join-button" id="joinButton">
|
|
||||||
Join
|
|
||||||
</button>
|
|
||||||
<div class="location-text">Live Location</div>
|
|
||||||
<div class="location-users" id="locationUsers">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>-->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages Container -->
|
<!-- Messages Container -->
|
||||||
<!--<div class="messages-container" style="background-image: url('cdn/background.png'); backdrop-filter: blur(10px); background-size: cover; background-position: center; background-repeat: no-repeat;">-->
|
|
||||||
<div class="messages-container"></div>
|
<div class="messages-container"></div>
|
||||||
|
|
||||||
<!-- Input Container -->
|
<!-- Input Container -->
|
||||||
@@ -55,6 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import markdownit from 'https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/+esm';
|
import markdownit from 'https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/+esm';
|
||||||
@@ -69,11 +99,12 @@
|
|||||||
} catch (__) {}
|
} catch (__) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''; // use external default escaping
|
return '';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const user_id = {{ user_id }};
|
let channel_id = 1;
|
||||||
|
const user_id = 1; // {{ user_id }}
|
||||||
var users = {};
|
var users = {};
|
||||||
|
|
||||||
// Handle message sending
|
// Handle message sending
|
||||||
@@ -110,7 +141,7 @@
|
|||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
if (message) {
|
if (message) {
|
||||||
fetch("/api/chat", {
|
fetch(`/api/chat/${channel_id}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
user_id: user_id,
|
user_id: user_id,
|
||||||
@@ -132,6 +163,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let messageSource;
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const userIds = await fetch("/api/users/")
|
const userIds = await fetch("/api/users/")
|
||||||
@@ -151,7 +184,11 @@
|
|||||||
|
|
||||||
console.log('Users loaded:', users);
|
console.log('Users loaded:', users);
|
||||||
|
|
||||||
const messageSource = new EventSource("/api/events");
|
if (messageSource) {
|
||||||
|
messageSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
messageSource = new EventSource(`/api/events/${channel_id}`);
|
||||||
messageSource.onopen = () => messagesContainer.innerHTML = '';
|
messageSource.onopen = () => messagesContainer.innerHTML = '';
|
||||||
messageSource.onmessage = (event) => insertMessage(JSON.parse(event.data));
|
messageSource.onmessage = (event) => insertMessage(JSON.parse(event.data));
|
||||||
messageSource.onerror = (error) => {
|
messageSource.onerror = (error) => {
|
||||||
@@ -163,6 +200,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle channel switching
|
||||||
|
document.querySelectorAll('.channel-item').forEach(item => {
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.channel-item').forEach(i => i.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
channel_id = parseInt(this.dataset.channelId);
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar toggle
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||||
|
|
||||||
|
sidebarToggle.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar resize
|
||||||
|
const resizeHandle = document.querySelector('.resize-handle');
|
||||||
|
let isResizing = false;
|
||||||
|
|
||||||
|
resizeHandle.addEventListener('mousedown', (e) => {
|
||||||
|
isResizing = true;
|
||||||
|
document.body.style.cursor = 'ew-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const newWidth = e.clientX;
|
||||||
|
if (newWidth >= 180 && newWidth <= 400) {
|
||||||
|
sidebar.style.width = newWidth + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isResizing) {
|
||||||
|
isResizing = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env nu
|
||||||
|
|
||||||
|
let DATABASE_URL = $env.DATABASE_URL
|
||||||
|
|
||||||
|
# Fetch all messages, optionally filtered by channel
|
||||||
|
def messages [channel_id?: int] {
|
||||||
|
let query = if ($channel_id | is-empty) {
|
||||||
|
"COPY (SELECT m.id, m.channel_id, m.user_id, u.username, m.content, m.created_at, m.updated_at, m.is_edited FROM messages m JOIN users u ON m.user_id = u.id ORDER BY m.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
} else {
|
||||||
|
$"COPY \(SELECT m.id, m.channel_id, m.user_id, u.username, m.content, m.created_at, m.updated_at, m.is_edited FROM messages m JOIN users u ON m.user_id = u.id WHERE m.channel_id = ($channel_id) ORDER BY m.created_at DESC\) TO STDOUT CSV HEADER;"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql $DATABASE_URL -c $query | from csv
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch all users
|
||||||
|
def users [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT id, username, display_name, email, twofa_enabled, created_at, updated_at FROM users ORDER BY created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch specific user by ID or username
|
||||||
|
def user [identifier: string] {
|
||||||
|
let query = if ($identifier | into int | is-not-empty) {
|
||||||
|
$"COPY \(SELECT id, username, display_name, email, twofa_enabled, created_at, updated_at FROM users WHERE id = ($identifier)\) TO STDOUT CSV HEADER;"
|
||||||
|
} else {
|
||||||
|
$"COPY \(SELECT id, username, display_name, email, twofa_enabled, created_at, updated_at FROM users WHERE username = '($identifier)'\) TO STDOUT CSV HEADER;"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql $DATABASE_URL -c $query | from csv
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch all channels
|
||||||
|
def channels [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT c.id, c.name, c.created_at, c.updated_at, COUNT(m.id) as message_count FROM channels c LEFT JOIN messages m ON c.id = m.channel_id GROUP BY c.id ORDER BY c.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch specific channel by ID
|
||||||
|
def channel [channel_id: int] {
|
||||||
|
psql $DATABASE_URL -c $"COPY \(SELECT c.id, c.name, c.created_at, c.updated_at, COUNT\(m.id\) as message_count FROM channels c LEFT JOIN messages m ON c.id = m.channel_id WHERE c.id = ($channel_id) GROUP BY c.id\) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch all sessions
|
||||||
|
def sessions [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT s.id, s.user_id, u.username, s.token, s.created_at, s.expires_at FROM sessions s JOIN users u ON s.user_id = u.id ORDER BY s.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch active sessions only
|
||||||
|
def active-sessions [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT s.id, s.user_id, u.username, s.token, s.created_at, s.expires_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.expires_at > NOW() ORDER BY s.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch all access codes
|
||||||
|
def access-codes [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT ac.id, ac.creator_id, u.username as creator, ac.code, ac.name, ac.uses, ac.max_uses, ac.created_at, ac.expires_at FROM access_codes ac JOIN users u ON ac.creator_id = u.id ORDER BY ac.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch valid (non-expired, not fully used) access codes
|
||||||
|
def valid-access-codes [] {
|
||||||
|
psql $DATABASE_URL -c "COPY (SELECT ac.id, ac.creator_id, u.username as creator, ac.code, ac.name, ac.uses, ac.max_uses, ac.created_at, ac.expires_at FROM access_codes ac JOIN users u ON ac.creator_id = u.id WHERE ac.expires_at > NOW() AND ac.uses < ac.max_uses ORDER BY ac.created_at DESC) TO STDOUT CSV HEADER;"
|
||||||
|
| from csv | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch message attachments
|
||||||
|
def attachments [message_id?: int] {
|
||||||
|
let query = if ($message_id | is-empty) {
|
||||||
|
"COPY (SELECT a.id, a.message_id, a.path, m.content as message_content, u.username FROM attachments a JOIN messages m ON a.message_id = m.id JOIN users u ON m.user_id = u.id ORDER BY a.id DESC) TO STDOUT CSV HEADER;"
|
||||||
|
} else {
|
||||||
|
$"COPY \(SELECT a.id, a.message_id, a.path, m.content as message_content FROM attachments a JOIN messages m ON a.message_id = m.id WHERE a.message_id = ($message_id)\) TO STDOUT CSV HEADER;"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql $DATABASE_URL -c $query | from csv
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get database statistics
|
||||||
|
def stats [] {
|
||||||
|
let user_count = (psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM users;" | str trim | into int)
|
||||||
|
let channel_count = (psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM channels;" | str trim | into int)
|
||||||
|
let message_count = (psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM messages;" | str trim | into int)
|
||||||
|
let session_count = (psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM sessions WHERE expires_at > NOW();" | str trim | into int)
|
||||||
|
let access_code_count = (psql $DATABASE_URL -t -c "SELECT COUNT(*) FROM access_codes WHERE expires_at > NOW() AND uses < max_uses;" | str trim | into int)
|
||||||
|
|
||||||
|
{
|
||||||
|
users: $user_count,
|
||||||
|
channels: $channel_count,
|
||||||
|
messages: $message_count,
|
||||||
|
active_sessions: $session_count,
|
||||||
|
valid_access_codes: $access_code_count
|
||||||
|
} | print
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display help menu
|
||||||
|
def help-menu [] {
|
||||||
|
print "Available commands:"
|
||||||
|
print " messages [channel_id] - Fetch all messages or messages from a specific channel"
|
||||||
|
print " users - Fetch all users"
|
||||||
|
print " user <id|username> - Fetch specific user by ID or username"
|
||||||
|
print " channels - Fetch all channels"
|
||||||
|
print " channel <id> - Fetch specific channel by ID"
|
||||||
|
print " sessions - Fetch all sessions"
|
||||||
|
print " active-sessions - Fetch only active (non-expired) sessions"
|
||||||
|
print " access-codes - Fetch all access codes"
|
||||||
|
print " valid-access-codes - Fetch valid (non-expired, available) access codes"
|
||||||
|
print " attachments [message_id] - Fetch all attachments or attachments for a message"
|
||||||
|
print " stats - Show database statistics"
|
||||||
|
print " help - Show this help menu"
|
||||||
|
print " exit - Exit the interface"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main interactive loop
|
||||||
|
def main [] {
|
||||||
|
print "\n=== Database Query Interface ==="
|
||||||
|
help-menu
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print ""
|
||||||
|
let input = (input "Enter command > ")
|
||||||
|
let parts = ($input | split words)
|
||||||
|
let cmd = ($parts | first)
|
||||||
|
|
||||||
|
match $cmd {
|
||||||
|
"messages" => {
|
||||||
|
if ($parts | length) > 1 {
|
||||||
|
let channel_id = ($parts | get 1 | into int)
|
||||||
|
messages $channel_id | print
|
||||||
|
} else {
|
||||||
|
messages | print
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"users" => users,
|
||||||
|
"user" => {
|
||||||
|
if ($parts | length) > 1 {
|
||||||
|
user ($parts | get 1) | print
|
||||||
|
} else {
|
||||||
|
print "Usage: user <id|username>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels" => channels,
|
||||||
|
"channel" => {
|
||||||
|
if ($parts | length) > 1 {
|
||||||
|
let channel_id = ($parts | get 1 | into int)
|
||||||
|
channel $channel_id | print
|
||||||
|
} else {
|
||||||
|
print "Usage: channel <id>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions" => sessions,
|
||||||
|
"active-sessions" => active-sessions,
|
||||||
|
"access-codes" => access-codes,
|
||||||
|
"valid-access-codes" => valid-access-codes,
|
||||||
|
"attachments" => {
|
||||||
|
if ($parts | length) > 1 {
|
||||||
|
let message_id = ($parts | get 1 | into int)
|
||||||
|
attachments $message_id | print
|
||||||
|
} else {
|
||||||
|
attachments | print
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stats" => stats,
|
||||||
|
"help" => help-menu,
|
||||||
|
"exit" => break,
|
||||||
|
_ => $"Unknown command: \($cmd). Type 'help' for available commands."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print "\nGoodbye!"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user