split frontend and backend to initialize tauri frontend

This commit is contained in:
2025-12-15 13:42:55 +01:00
parent 30f4155369
commit 43a55a9a7c
11 changed files with 3637 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
/result
/uploads

3058
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "chatapp"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.99"
argon2 = "0.5.3"
axum = { version = "0.8.4", features = ["multipart"] }
chrono = { version = "0.4.42", features = ["serde"] }
jsonwebtoken = "9.3.1"
password-hash = "0.5.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.6.6", features = ["cors", "limit"] }
tower_governor = "0.8.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
uuid = { version = "1.19.0", features = ["serde", "v7"] }
validator = "0.20.0"

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
# chat app backend
## Configuration
- Specify the JWT secret with the `CHATAPP_JWT_SECRET` environment variable.
- Specify the server's port with the `CHATAPP_PORT` environment variable. Defaults to `8080`.
- To enable user registration, pass in `CHATAPP_ALLOW_REGISTRATION=true`.

77
src/auth.rs Normal file
View File

@@ -0,0 +1,77 @@
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use password_hash::SaltString;
use password_hash::rand_core::OsRng;
use axum::http::{HeaderMap, StatusCode};
use uuid::Uuid;
const DEFAULT_SECRET_KEY: &str = "43aaf85b92f1ae6fbcef7732c50a0904";
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub exp: usize,
}
pub fn hash_password(password: &str) -> Result<String, String> {
let salt = SaltString::generate(OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| e.to_string())
.map(|ph| ph.to_string())
}
pub fn verify_password(hash: &str, password: &str) -> bool {
let parsed_hash = PasswordHash::new(hash).ok();
if let Some(parsed_hash) = parsed_hash {
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
} else {
false
}
}
pub fn create_jwt(user_uuid: Uuid) -> Result<String, String> {
let expiration = Utc::now()
.checked_add_signed(Duration::minutes(15))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
sub: user_uuid,
exp: expiration as usize,
};
let secret =
std::env::var("CHATAPP_JWT_SECRET").unwrap_or_else(|_| DEFAULT_SECRET_KEY.to_string());
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_ref()),
)
.map_err(|_| "Token creation failed".into())
}
pub fn verify_jwt(headers: HeaderMap) -> Result<Claims, (StatusCode, String)> {
let token = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or((StatusCode::UNAUTHORIZED, "Missing token".to_string()))?;
let secret =
std::env::var("CHATAPP_JWT_SECRET").unwrap_or_else(|_| DEFAULT_SECRET_KEY.to_string());
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".to_string()))
}

25
src/db.rs Normal file
View File

@@ -0,0 +1,25 @@
use axum::http::StatusCode;
use sqlx::PgPool;
use uuid::Uuid;
pub async fn init_db() -> Result<PgPool, sqlx::Error> {
let database_url = "postgres://chatapp:secret@localhost:5432/chatapp";
PgPool::connect(database_url).await
}
pub async fn user_id_from_uuid(db: &PgPool, user_uuid: Uuid) -> Result<i32, (StatusCode, String)> {
sqlx::query_scalar("SELECT id FROM user_ WHERE uuid = $1")
.bind(user_uuid)
.fetch_one(db)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, String::from("Wrong token")))
}
pub async fn room_id_from_uuid(db: &PgPool, room_uuid: Uuid) -> Result<i32, (StatusCode, String)> {
sqlx::query_scalar("SELECT id FROM room_ WHERE uuid = $1")
.bind(room_uuid)
.fetch_one(db)
.await
// FIX: hmm probably the wrong error here
.map_err(|_| (StatusCode::UNAUTHORIZED, String::from("Wrong token")))
}

66
src/main.rs Normal file
View File

@@ -0,0 +1,66 @@
use axum::{
Extension, Router,
http::{Method, header},
};
use std::{env::var, net::SocketAddr, time::Duration};
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
use tower_http::cors::{Any, CorsLayer};
mod auth;
mod db;
mod routes;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let subscriber = tracing_subscriber::FmtSubscriber::new();
tracing::subscriber::set_global_default(subscriber).unwrap();
tracing::info!("Connecting to database...");
let db_pool = db::init_db().await?;
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);
let governor_conf = GovernorConfigBuilder::default()
.per_second(5)
.burst_size(10)
.finish()
.unwrap();
let governor_limiter = governor_conf.limiter().clone();
// a separate background task to clean up
let interval = Duration::from_secs(60);
std::thread::spawn(move || {
loop {
std::thread::sleep(interval);
// tracing::info!("rate limiting storage size: {}", governor_limiter.len());
governor_limiter.retain_recent();
}
});
let app = Router::new()
.merge(routes::users::routes())
.merge(routes::rooms::routes())
.merge(routes::messages::routes())
.layer(Extension(db_pool))
.layer(cors)
.layer(GovernorLayer::new(governor_conf));
let port = var("CHATAPP_SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!("Listening on {addr}");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
Ok(())
}

117
src/routes/messages.rs Normal file
View File

@@ -0,0 +1,117 @@
use axum::{
Extension, Json, Router,
extract::Path,
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::db::user_id_from_uuid;
use crate::{auth::verify_jwt, db::room_id_from_uuid};
#[derive(sqlx::FromRow, serde::Serialize, Debug)]
pub struct Message {
pub sender: Uuid,
pub message_type: String,
pub content: String,
}
#[derive(serde::Deserialize)]
pub struct NewMessagePayload {
pub message_type: String,
pub content: String,
}
pub fn routes() -> Router {
Router::new()
.route("/messages/{room_uuid}", get(list_messages))
.route("/messages/{room_uuid}", post(create_message))
}
async fn list_messages(
Path(room_uuid): Path<Uuid>,
headers: HeaderMap,
Extension(db): Extension<PgPool>,
) -> Result<Json<Vec<Message>>, (StatusCode, String)> {
let claims = verify_jwt(headers)?;
let user_id = user_id_from_uuid(&db, claims.sub).await?;
let room_id = room_id_from_uuid(&db, room_uuid).await?;
let membership: Vec<i32> =
sqlx::query_scalar("SELECT user_id FROM membership_ WHERE user_id = $1 AND room = $2")
.bind(user_id)
.bind(room_id)
.fetch_all(&db)
.await
.unwrap_or_else(|_| Vec::new());
if membership.is_empty() {
return Err((
StatusCode::UNAUTHORIZED,
String::from("You are not a member of this room"),
));
}
let messages = sqlx::query_as::<_, Message>(
r#"
SELECT
u.uuid AS sender,
r.uuid AS room,
m.type AS message_type,
m.content
FROM message_ m
JOIN user_ u ON u.id = m.sender
JOIN room_ r ON r.id = m.room
WHERE m.room = $1
ORDER BY m.id
"#,
)
.bind(room_id)
.fetch_all(&db)
.await
.map_err(|e| {
tracing::error!("failed to list messages: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list messages".into(),
)
})?;
Ok(Json(messages))
}
async fn create_message(
Path(room_uuid): Path<Uuid>,
Extension(db): Extension<PgPool>,
headers: HeaderMap,
Json(payload): Json<NewMessagePayload>,
) -> Result<(StatusCode, Json<Message>), (StatusCode, String)> {
let claims = verify_jwt(headers)?;
let user_id = user_id_from_uuid(&db, claims.sub).await?;
let room_id = room_id_from_uuid(&db, room_uuid).await?;
sqlx::query(
"INSERT INTO message_ (sender, room, type, content)
VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(room_id)
.bind(&payload.message_type)
.bind(&payload.content)
.execute(&db)
.await
.map_err(|_| (StatusCode::BAD_REQUEST, format!("Could not create message")))?;
Ok((
StatusCode::CREATED,
Json(Message {
sender: claims.sub,
message_type: payload.message_type,
content: payload.content,
}),
))
}

3
src/routes/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod messages;
pub mod rooms;
pub mod users;

113
src/routes/rooms.rs Normal file
View File

@@ -0,0 +1,113 @@
use axum::{
Extension, Json, Router,
extract::Path,
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::verify_jwt;
use crate::db::user_id_from_uuid;
#[derive(sqlx::FromRow, serde::Serialize)]
pub struct Room {
pub uuid: Uuid,
pub owner: i32,
pub name: String,
}
#[derive(serde::Deserialize)]
pub struct NewRoomPayload {
pub name: String,
}
pub fn routes() -> Router {
Router::new()
.route("/rooms/{user_uuid}", get(list_rooms))
.route("/rooms", post(create_room))
.route("/rooms/{user_uuid}/{room_id}", get(get_room))
}
async fn list_rooms(
Path(user_uuid): Path<Uuid>,
headers: HeaderMap,
Extension(db): Extension<PgPool>,
) -> Result<Json<Vec<Room>>, (StatusCode, String)> {
let claims = verify_jwt(headers)?;
if claims.sub != user_uuid {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let user_id = user_id_from_uuid(&db, claims.sub).await?;
let rooms = sqlx::query_as::<_, Room>("SELECT uuid, owner, name FROM room_ WHERE owner = $1")
.bind(user_id)
.fetch_all(&db)
.await
.unwrap_or_else(|e| {
tracing::error!("faied to list rooms: {e}");
Vec::new()
});
Ok(Json(rooms))
}
async fn create_room(
Extension(db): Extension<PgPool>,
headers: HeaderMap,
Json(payload): Json<NewRoomPayload>,
) -> Result<(StatusCode, Json<Room>), (StatusCode, String)> {
let claims = verify_jwt(headers)?;
let user_id = user_id_from_uuid(&db, claims.sub).await?;
let room_uuid = uuid::Uuid::now_v7();
sqlx::query(
"INSERT INTO room_ (uuid, owner, name)
VALUES ($1, $2, $3)",
)
.bind(room_uuid)
.bind(user_id)
.bind(&payload.name)
.execute(&db)
.await
.map_err(|_| (StatusCode::BAD_REQUEST, format!("Could not create room")))?;
Ok((
StatusCode::CREATED,
Json(Room {
uuid: room_uuid,
owner: user_id,
name: payload.name,
}),
))
}
async fn get_room(
Path((user_uuid, room_uuid)): Path<(Uuid, Uuid)>,
headers: HeaderMap,
Extension(db): Extension<PgPool>,
) -> Result<Json<Room>, (StatusCode, String)> {
let claims = verify_jwt(headers)?;
if claims.sub != user_uuid {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let user_id = user_id_from_uuid(&db, user_uuid).await?;
let room: Room =
sqlx::query_as("SELECT uuid, owner, name FROM room_ WHERE uuid = $1 AND owner = $2")
.bind(room_uuid)
.bind(user_id)
.fetch_one(&db)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Room not found".to_string()))?;
Ok(Json(Room {
uuid: room_uuid,
owner: room.owner,
name: room.name,
}))
}

146
src/routes/users.rs Normal file
View File

@@ -0,0 +1,146 @@
use axum::{
Extension, Json, Router, extract::Request, http::StatusCode, middleware::Next,
response::Response, routing::post,
};
use sqlx::PgPool;
use std::env;
use uuid::Uuid;
use validator::ValidateEmail;
use crate::auth::{create_jwt, hash_password, verify_password};
const DUMMY_HASH: &str = "$argon2id$v=19$m=4096,t=3,p=1$YWFhYWFhYWFhYWFhYWFhYQ$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[derive(sqlx::FromRow, serde::Serialize)]
pub struct User {
pub uuid: Uuid,
pub username: String,
pub password_hash: String,
pub email: String,
}
#[derive(serde::Deserialize)]
pub struct LoginPayload {
pub email: String,
pub password: String,
}
#[derive(serde::Serialize)]
pub struct LoginResponse {
pub uuid: Uuid,
pub token: String,
}
#[derive(serde::Deserialize)]
pub struct NewUserPayload {
pub email: String,
pub username: String,
pub password: String,
}
pub fn routes() -> Router {
Router::new()
.route("/login", post(login))
.route("/register", post(register_user))
.layer(axum::middleware::from_fn(registration_guard))
}
async fn registration_guard(req: Request, next: Next) -> Result<Response, StatusCode> {
if req.uri().path() == "/register"
&& env::var("CHATAPP_ALLOW_REGISTRATION").map_or(true, |v| v.to_lowercase() == "false")
{
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
pub async fn login(
Extension(db): Extension<PgPool>,
Json(payload): Json<LoginPayload>,
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
let user = sqlx::query_as::<_, User>(
"SELECT uuid, email, username, password_hash FROM user_ WHERE email = $1",
)
.bind(&payload.email)
.fetch_optional(&db)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "DB error".into()))?;
let (user_uuid, password_hash) = if let Some(u) = user {
(u.uuid, u.password_hash)
} else {
// timing shield
(uuid::Uuid::now_v7(), DUMMY_HASH.to_string())
};
if !verify_password(&password_hash, &payload.password) {
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
}
let token = create_jwt(user_uuid).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(LoginResponse {
uuid: user_uuid,
token,
}))
}
pub async fn register_user(
Extension(db): Extension<PgPool>,
Json(payload): Json<NewUserPayload>,
) -> Result<(StatusCode, Json<LoginResponse>), (StatusCode, String)> {
if payload.email.is_empty() || payload.username.is_empty() || payload.password.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Cannot create a user with empty fields".into(),
));
}
if !ValidateEmail::validate_email(&payload.email) {
return Err((StatusCode::BAD_REQUEST, "Invalid email format".into()));
}
if payload.password.len() < 8 {
return Err((
StatusCode::BAD_REQUEST,
"Password must be at least 8 characters long".into(),
));
}
let password_hash =
hash_password(&payload.password).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
let user_uuid = uuid::Uuid::now_v7();
sqlx::query(
"INSERT INTO user_ (uuid, username, email, password_hash)
VALUES ($1, $2, $3, $4)",
)
.bind(user_uuid)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.execute(&db)
.await
.map_err(|e| {
if let Some(db_err) = e.as_database_error() {
if db_err.code().map(|c| c == "23505").unwrap_or(false) {
return (
StatusCode::CONFLICT,
"Email or username already taken".into(),
);
}
}
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
let token = create_jwt(user_uuid).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok((
StatusCode::CREATED,
Json(LoginResponse {
uuid: user_uuid,
token,
}),
))
}