Compare commits

9 Commits
Author SHA1 Message Date
wt 2e8eef8d2a Merge branch 'develop' of gitea.wthome.ru:TolaMironcenko/msgs into develop
Continuous integration / Check (push) Failing after 38s
Continuous integration / Rustfmt (push) Successful in 10m4s
Continuous integration / build (push) Successful in 10m41s
Continuous integration / build-release (push) Successful in 11m51s
2026-08-14 15:02:56 +07:00
wt 97d170e10a update ui 2026-08-14 15:02:45 +07:00
wt 25b1a6292f Update upload.rs
Continuous integration / Check (push) Successful in 39s
Continuous integration / Rustfmt (push) Successful in 33s
Continuous integration / build (push) Successful in 2m8s
Continuous integration / build-release (push) Successful in 1m9s
2026-02-17 22:56:59 +07:00
wt fe918433e8 Update upload.rs
Continuous integration / Check (push) Successful in 38s
Continuous integration / Rustfmt (push) Failing after 32s
Continuous integration / build (push) Successful in 59s
Continuous integration / build-release (push) Successful in 1m3s
2026-02-15 21:42:44 +07:00
wt 9f0681563e added swagget-ui
Continuous integration / Check (push) Successful in 42s
Continuous integration / Rustfmt (push) Successful in 34s
Continuous integration / build (push) Successful in 43s
Continuous integration / build-release (push) Successful in 1m12s
2026-02-15 18:04:11 +07:00
wt 78ca22e1b7 added audio and video messages
Continuous integration / Check (push) Successful in 39s
Continuous integration / Rustfmt (push) Successful in 34s
Continuous integration / build (push) Successful in 37s
Continuous integration / build-release (push) Successful in 1m3s
2026-02-15 17:40:32 +07:00
wt 9016889a78 added file audio video and files
Continuous integration / Check (push) Failing after 10s
Continuous integration / Rustfmt (push) Successful in 36s
Continuous integration / build (push) Successful in 36s
Continuous integration / build-release (push) Successful in 57s
2026-02-15 16:55:51 +07:00
wt 8cb6143bac Update register.rs
Continuous integration / Check (push) Successful in 1m7s
Continuous integration / Rustfmt (push) Successful in 41s
Continuous integration / build (push) Successful in 1m6s
Continuous integration / build-release (push) Successful in 1m38s
2026-02-15 13:49:11 +07:00
wt bb87570ef9 udate index.css
Continuous integration / Rustfmt (push) Successful in 30s
Continuous integration / build-release (push) Successful in 1m15s
Continuous integration / Check (push) Has been cancelled
Continuous integration / build (push) Failing after 16m50s
2025-12-01 15:38:41 +07:00
25 changed files with 2113 additions and 45 deletions
Generated
+389
View File
@@ -32,6 +32,15 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -47,6 +56,12 @@ dependencies = [
"libc",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -527,6 +542,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1054,6 +1075,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
@@ -1262,10 +1285,13 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"symphonia",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
"uuid",
]
@@ -1591,6 +1617,30 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
@@ -1683,6 +1733,35 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]]
name = "ron"
version = "0.7.1"
@@ -1714,6 +1793,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.111",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.18.0"
@@ -1749,6 +1862,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -2188,6 +2310,201 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-caf",
"symphonia-format-isomp4",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-caf"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -2593,6 +2910,47 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"regex",
"syn 2.0.111",
]
[[package]]
name = "utoipa-swagger-ui"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da"
dependencies = [
"axum",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.18.1"
@@ -2622,6 +2980,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2704,6 +3072,15 @@ dependencies = [
"wasite",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -3036,6 +3413,18 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]
[[package]]
name = "zune-inflate"
version = "0.2.54"
+4 -1
View File
@@ -18,4 +18,7 @@ rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] }
image = "0.24"
config = "0.13"
config = "0.13"
symphonia = { version = "0.5", features = ["all"] }
utoipa = { version = "4.2", features = ["axum_extras", "chrono"] }
utoipa-swagger-ui = { version = "6.0", features = ["axum"] }
+2 -1
View File
@@ -10,7 +10,8 @@
"files": {
"avatars_dir": "web/avatars",
"web_dir": "web",
"max_avatar_size": 5242880
"max_avatar_size": 5242880,
"upload_dir": "web/uploads"
},
"limits": {
"max_message_length": 4096,
+1
View File
@@ -6,4 +6,5 @@ pub mod profile;
pub mod register;
pub mod search;
pub mod security;
pub mod upload;
pub mod websock;
+9
View File
@@ -6,6 +6,15 @@ use sqlx::Row;
use std::sync::Arc;
// УПРОЩЕННАЯ версия получения чатов
#[utoipa::path(
get,
path = "/api/chats",
responses(
(status = 200, description = "List of chat partners", body = Vec<ChatPartner>),
(status = 401, description = "Unauthorized", body = Vec<ChatPartner>)
),
security(("bearerAuth" = []))
)]
pub async fn get_chats_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
+9
View File
@@ -7,6 +7,15 @@ use chrono::{Duration, Utc};
use sqlx::Row;
use std::sync::Arc;
#[utoipa::path(
post,
path = "/api/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = AuthResponse),
(status = 400, description = "Invalid credentials", body = AuthResponse)
)
)]
pub async fn login_handler(
State(state): State<Arc<state::AppState>>,
Json(payload): Json<model::LoginRequest>,
+10
View File
@@ -6,6 +6,16 @@ use axum::extract::{Json, State};
use std::sync::Arc;
// Mark messages as read endpoint
#[utoipa::path(
post,
path = "/api/mark-read",
request_body = MarkReadRequest,
responses(
(status = 200, description = "Messages marked as read", body = serde_json::Value),
(status = 401, description = "Unauthorized", body = serde_json::Value)
),
security(("bearerAuth" = []))
)]
pub async fn mark_read_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
+59
View File
@@ -4,8 +4,19 @@ use crate::utils;
use axum::extract::{Json, State, ws::Message};
use sqlx::Row;
use std::sync::Arc;
use tokio::fs;
// Delete message endpoint
#[utoipa::path(
post,
path = "/api/delete-message",
request_body = DeleteMessageRequest,
responses(
(status = 200, description = "Message deletion result", body = DeleteMessageResponse),
(status = 401, description = "Unauthorized", body = DeleteMessageResponse)
),
security(("bearerAuth" = []))
)]
pub async fn delete_message_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
@@ -66,6 +77,32 @@ pub async fn delete_message_handler(
});
}
// Получаем тип сообщения и его содержимое
let message_details = sqlx::query("SELECT message_type, content FROM messages WHERE id = ?")
.bind(&payload.message_id)
.fetch_optional(&state.db_pool)
.await;
let (message_type, content) = match message_details {
Ok(Some(row)) => (
row.get::<String, _>("message_type"),
row.get::<String, _>("content"),
),
Ok(None) => {
return Json(model::DeleteMessageResponse {
success: false,
message: "Message not found".to_string(),
});
}
Err(e) => {
eprintln!("Error fetching message details: {}", e);
return Json(model::DeleteMessageResponse {
success: false,
message: "Database error".to_string(),
});
}
};
if payload.for_everyone {
// Удаление для всех
if from_user != username {
@@ -75,6 +112,28 @@ pub async fn delete_message_handler(
});
}
// Если это сообщение с файлом, удаляем физический файл
if message_type != "text" {
// Создаем временную структуру для извлечения пути к файлу
#[derive(serde::Deserialize)]
struct FilePathExtractor {
filepath: String,
}
if let Ok(file_extractor) = serde_json::from_str::<FilePathExtractor>(&content) {
let file_path =
format!("{}{}", state.config.files.web_dir, file_extractor.filepath);
// Пытаемся удалить файл из файловой системы
if let Err(e) = fs::remove_file(&file_path).await {
eprintln!("Error deleting file {}: {}", file_path, e);
// Продолжаем выполнение, даже если удаление файла не удалось
} else {
println!("Deleted file: {}", file_path);
}
}
}
let result = sqlx::query("UPDATE messages SET deleted_for_everyone = TRUE WHERE id = ?")
.bind(&payload.message_id)
.execute(&state.db_pool)
+10
View File
@@ -6,6 +6,16 @@ use std::sync::Arc;
use uuid::Uuid;
// Upload avatar endpoint
#[utoipa::path(
post,
path = "/api/upload-avatar",
request_body(content = Vec<u8>, description = "Avatar image file", content_type = "multipart/form-data"),
responses(
(status = 200, description = "Avatar upload result", body = UploadAvatarResponse),
(status = 401, description = "Unauthorized", body = UploadAvatarResponse)
),
security(("bearerAuth" = []))
)]
pub async fn upload_avatar_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
+9
View File
@@ -5,6 +5,15 @@ use axum::extract::{Json, State};
use sqlx::Row;
use std::sync::Arc;
#[utoipa::path(
get,
path = "/api/profile",
responses(
(status = 200, description = "User profile information", body = UserProfile),
(status = 401, description = "Unauthorized", body = UserProfile)
),
security(("bearerAuth" = []))
)]
pub async fn profile_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
+10 -1
View File
@@ -6,6 +6,15 @@ use bcrypt::{DEFAULT_COST, hash};
use chrono::{Duration, Utc};
use std::sync::Arc;
#[utoipa::path(
post,
path = "/api/register",
request_body = RegisterRequest,
responses(
(status = 200, description = "Registration successful", body = AuthResponse),
(status = 400, description = "Registration failed", body = AuthResponse)
)
)]
pub async fn register_handler(
State(state): State<Arc<state::AppState>>,
Json(payload): Json<model::RegisterRequest>,
@@ -20,7 +29,7 @@ pub async fn register_handler(
});
}
if payload.username.len() < 3
if payload.username.len() < 2
|| payload.username.len() > state.config.limits.max_username_length
{
return Json(model::AuthResponse {
+10
View File
@@ -5,6 +5,16 @@ use sqlx::Row;
use std::sync::Arc;
// New endpoint to search users
#[utoipa::path(
get,
path = "/api/search-users",
params(
("q" = String, Query, description = "Search query string")
),
responses(
(status = 200, description = "List of matching users", body = Vec<ChatPartner>)
)
)]
pub async fn search_users_handler(
State(state): State<Arc<state::AppState>>,
Query(params): Query<model::SearchQuery>,
+9
View File
@@ -5,6 +5,15 @@ use axum::extract::{Json, State};
use sqlx::Row;
use std::sync::Arc;
#[utoipa::path(
post,
path = "/api/validate-token",
request_body = ValidateTokenRequest,
responses(
(status = 200, description = "Token validation result", body = ValidateTokenResponse),
(status = 400, description = "Invalid token", body = ValidateTokenResponse)
)
)]
pub async fn validate_token_handler(
State(state): State<Arc<state::AppState>>,
Json(payload): Json<model::ValidateTokenRequest>,
+394
View File
@@ -0,0 +1,394 @@
use crate::model::{AudioInfo, FileInfo, PhotoInfo, VideoInfo};
use crate::state;
use crate::utils;
use axum::extract::{Multipart, State};
use axum::response::Json;
use image::GenericImageView;
use std::path::Path;
use std::sync::Arc;
use symphonia::core::formats::FormatOptions;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tokio::fs;
use tokio::io::AsyncWriteExt;
#[utoipa::path(
post,
path = "/api/upload",
request_body(content = Vec<u8>, description = "File to upload", content_type = "multipart/form-data"),
responses(
(status = 200, description = "File upload result", body = serde_json::Value),
(status = 401, description = "Unauthorized", body = serde_json::Value)
),
security(("bearerAuth" = []))
)]
pub async fn upload_file_handler(
State(state): State<Arc<state::AppState>>,
headers: axum::http::HeaderMap,
mut multipart: Multipart,
) -> Json<serde_json::Value> {
// Extract token from header
let token = if let Some(auth_header) = headers.get("authorization") {
auth_header.to_str().unwrap_or("").replace("Bearer ", "")
} else {
return Json(serde_json::json!({"success": false, "error": "No token"}));
};
// Get current user from token
match utils::verify_token(&state.db_pool, &token).await {
Ok(Some(_user)) => {} // We verify the token is valid, but don't need the username for file upload
_ => return Json(serde_json::json!({"success": false, "error": "Invalid token"})),
};
// Process uploaded file
while let Some(field) = match multipart.next_field().await {
Ok(field_opt) => field_opt,
Err(e) => {
eprintln!("Error reading multipart field: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error reading multipart data"}),
);
}
} {
let name = field.name().unwrap_or("unknown").to_string();
if name == "file" {
// Get field properties before consuming the field
let filename = field.file_name().unwrap_or("unknown").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let file_data = match field.bytes().await {
Ok(data) => {
if data.is_empty() {
eprintln!(
"Received empty file data for file: {}, content-type: {}",
filename, content_type
);
return Json(
serde_json::json!({"success": false, "error": "Empty file received"}),
);
}
// Check file size against the configured limit
if data.len() > state.config.files.max_upload_size {
eprintln!(
"File {} exceeds size limit. Size: {} bytes, Limit: {} bytes",
filename,
data.len(),
state.config.files.max_upload_size
);
return Json(
serde_json::json!({"success": false, "error": "File exceeds size limit"}),
);
}
eprintln!(
"Successfully read {} bytes for file: {}, content-type: {}",
data.len(),
filename,
content_type
);
data
}
Err(e) => {
eprintln!(
"Error reading file data for {}: {} - Request may not contain proper file data",
filename, e
);
eprintln!("Content-Type: {}, Filename: {}", content_type, filename);
return Json(
serde_json::json!({"success": false, "error": format!("No valid file data found in request for {}", filename)}),
);
}
};
// Determine file type and validate
let file_type = determine_file_type(&content_type, &filename);
if file_type.is_none() {
return Json(
serde_json::json!({"success": false, "error": "Unsupported file type"}),
);
}
// Generate unique filename to prevent conflicts
let ext = Path::new(&filename)
.extension()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("tmp");
let unique_filename = format!(
"{}_{}.{}",
chrono::Utc::now().timestamp(),
uuid::Uuid::new_v4().simple(),
ext
);
// Create upload directory if it doesn't exist
let upload_dir = &state.config.files.upload_dir;
if let Err(e) = fs::create_dir_all(upload_dir).await {
eprintln!("Error creating upload directory: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error creating upload directory"}),
);
}
// Save file
let filepath = format!("{}/{}", upload_dir, unique_filename);
eprintln!("Saving file to: {}", filepath);
let filepathweb = format!("/uploads/{}", unique_filename); // Web-accessible path - files are in web/uploads
let mut file = match fs::File::create(&filepath).await {
Ok(f) => f,
Err(e) => {
eprintln!("Error creating file: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error creating file"}),
);
}
};
if let Err(e) = file.write_all(&file_data).await {
eprintln!("Error writing file: {}", e);
return Json(serde_json::json!({"success": false, "error": "Error writing file"}));
}
drop(file); // Close the file
// Get file size
let metadata = match fs::metadata(&filepath).await {
Ok(m) => m,
Err(e) => {
eprintln!("Error getting file metadata: {}", e);
return Json(
serde_json::json!({"success": false, "error": "Error getting file metadata"}),
);
}
};
let size = metadata.len();
// Prepare response based on file type
let response = match file_type.unwrap() {
FileType::Image => {
// Try to get image dimensions
let img_result = image::open(&filepath);
let (width, height) = if let Ok(img) = img_result {
let (w, h) = img.dimensions();
(w, h)
} else {
(0, 0) // fallback if we can't read dimensions
};
let photo_info = PhotoInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
width,
height,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "photo",
"info": photo_info
}))
}
FileType::Video => {
// Extract duration for video files
let duration = extract_duration(&filepath).await;
let video_info = VideoInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
duration,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "video",
"info": video_info
}))
}
FileType::Audio => {
// Extract duration for audio files
let duration = extract_duration(&filepath).await;
let audio_info = AudioInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
duration,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "audio",
"info": audio_info
}))
}
FileType::Generic => {
let file_info = FileInfo {
filename: filename.clone(),
filepath: filepathweb.clone(),
size,
mime_type: content_type.clone(),
};
Json(serde_json::json!({
"success": true,
"file_type": "file",
"info": file_info
}))
}
};
return response; // Return the response from the function
}
}
Json(serde_json::json!({"success": false, "error": "No file found"}))
}
enum FileType {
Image,
Video,
Audio,
Generic,
}
// Function to extract duration from audio/video files
async fn extract_duration(filepath: &str) -> Option<f64> {
use std::panic;
use symphonia::core::io::MediaSourceStream;
// Wrap the Symphonia call in catch_unwind to prevent panics
let result = panic::catch_unwind(|| {
// Ensure the file is fully written before trying to read it
std::thread::sleep(std::time::Duration::from_millis(10));
// Open the file using std::fs::File to avoid async file issues with Symphonia
let file = match std::fs::File::open(filepath) {
Ok(file) => file,
Err(e) => {
eprintln!("Error opening media file: {}", e);
return None;
}
};
// Create a MediaSourceStream with proper buffering
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = Path::new(filepath).extension().and_then(|ext| ext.to_str()) {
hint.with_extension(ext);
}
// Configure format options to be more permissive with EBML formats like WebM
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts = MetadataOptions::default();
let probed = match symphonia::default::get_probe().format(
&hint,
mss,
&format_opts,
&metadata_opts,
) {
Ok(probed) => probed,
Err(e) => {
eprintln!("Error probing media file: {}", e);
return None;
}
};
// Try to get the duration from the format reader's capabilities
// Note: Symphonia doesn't have a direct duration() method, so we'll skip this approach
// and rely on track-based duration calculation
// Fallback: try to get duration from track parameters
let track = match probed
.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
{
Some(track) => track,
None => return None,
};
// Try to get duration from track's time base and frame count
let duration = track.codec_params.time_base.and_then(|tb| {
track
.codec_params
.n_frames
.and_then(|frames| Some(tb.calc_time(frames).seconds as f64))
});
duration
});
match result {
Ok(duration) => duration,
Err(panic_info) => {
eprintln!("Symphonia panic caught: {:?}", panic_info);
None
}
}
}
fn determine_file_type(content_type: &str, filename: &str) -> Option<FileType> {
let lower_ct = content_type.to_lowercase();
let lower_fn = filename.to_lowercase();
// Check content type first
if lower_ct.starts_with("image/") {
return Some(FileType::Image);
} else if lower_ct.starts_with("video/") {
return Some(FileType::Video);
} else if lower_ct.starts_with("audio/") {
return Some(FileType::Audio);
}
// Fallback to file extension - expanded list of supported formats
if lower_fn.ends_with(".jpg")
|| lower_fn.ends_with(".jpeg")
|| lower_fn.ends_with(".png")
|| lower_fn.ends_with(".gif")
|| lower_fn.ends_with(".webp")
|| lower_fn.ends_with(".bmp")
|| lower_fn.ends_with(".svg")
|| lower_fn.ends_with(".tiff")
|| lower_fn.ends_with(".tif")
{
return Some(FileType::Image);
} else if lower_fn.ends_with(".mp4")
|| lower_fn.ends_with(".avi")
|| lower_fn.ends_with(".mov")
|| lower_fn.ends_with(".mkv")
|| lower_fn.ends_with(".webm")
|| lower_fn.ends_with(".wmv")
|| lower_fn.ends_with(".flv")
|| lower_fn.ends_with(".m4v")
|| lower_fn.ends_with(".3gp")
|| lower_fn.ends_with(".mpeg")
|| lower_fn.ends_with(".mpg")
{
return Some(FileType::Video);
} else if lower_fn.ends_with(".mp3")
|| lower_fn.ends_with(".wav")
|| lower_fn.ends_with(".ogg")
|| lower_fn.ends_with(".flac")
|| lower_fn.ends_with(".m4a")
|| lower_fn.ends_with(".aac")
|| lower_fn.ends_with(".wma")
|| lower_fn.ends_with(".opus")
{
return Some(FileType::Audio);
}
Some(FileType::Generic)
}
+21 -1
View File
@@ -12,7 +12,27 @@ use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::sync::mpsc;
// WebSocket handlers
/// WebSocket endpoint for real-time chat messaging
///
/// ## Connection Flow
/// 1. Client connects to `/ws`
/// 2. Client sends authentication message: `{"type": "auth", "token": "your_token"}`
/// 3. Server responds with system message on success or error message on failure
/// 4. After successful authentication, client can send/receive chat messages
///
/// ## Message Types
/// - Authentication: `{"type": "auth", "token": "jwt_token"}`
/// - Chat message: `{"id": "...", "from_user": "...", "to_user": "...", "content": "...", "timestamp": 12345, "message_type": "text"}`
/// - System message: `{"type": "system", "content": "Connected successfully"}`
/// - Error message: `{"type": "error", "content": "Invalid token"}`
#[utoipa::path(
get,
path = "/ws",
responses(
(status = 101, description = "WebSocket upgrade successful")
),
tag = "WebSocket"
)]
pub async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<state::AppState>>,
+4
View File
@@ -26,7 +26,9 @@ pub struct DatabaseConfig {
pub struct FilesConfig {
pub avatars_dir: String,
pub web_dir: String,
pub upload_dir: String,
pub max_avatar_size: usize,
pub max_upload_size: usize,
}
#[derive(Debug, Deserialize, Clone)]
@@ -53,7 +55,9 @@ impl AppConfig {
[files]
avatars_dir = "web/avatars"
web_dir = "web"
upload_dir = "uploads"
max_avatar_size = 5242880
max_upload_size = 52428800 # 50MB
[limits]
max_message_length = 4096
+11
View File
@@ -1,17 +1,22 @@
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{get, post},
};
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use tokio::sync::RwLock;
use tower_http::services::ServeDir;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
mod api;
mod conf;
mod db;
mod model;
mod openapi;
mod state;
mod utils;
mod websocket_doc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -25,6 +30,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create directories if they don't exist
tokio::fs::create_dir_all(&config.files.avatars_dir).await?;
tokio::fs::create_dir_all(&config.files.web_dir).await?;
tokio::fs::create_dir_all(&config.files.upload_dir).await?;
// Initialize database with configured URL
let db_pool = db::init_db(&config.database.url).await?;
@@ -60,8 +66,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"/api/upload-avatar",
post(api::profile::avatar::upload_avatar_handler),
)
.route("/api/upload", post(api::upload::upload_file_handler))
.route("/api/profile", get(api::profile::get::profile_handler))
.merge(
SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()),
)
.nest_service("/", ServeDir::new(&state.config.files.web_dir))
.layer(DefaultBodyLimit::max(state.config.files.max_upload_size))
.with_state(state.clone());
let addr = format!("{}:{}", state.config.server.host, state.config.server.port)
+290 -14
View File
@@ -1,93 +1,369 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct AuthResponse {
/// Indicates if the operation was successful
pub success: bool,
/// Message describing the result
pub message: String,
/// Username of the authenticated user
pub username: Option<String>,
/// Authentication token
pub token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct RegisterRequest {
/// Phone number for registration
pub phone: String,
/// Desired username
pub username: String,
/// Password for account
pub password: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
/// Phone number associated with the account
pub phone: String,
/// Password for the account
pub password: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct ValidateTokenResponse {
/// Indicates if the token is valid
pub success: bool,
/// Username associated with the token
pub username: Option<String>,
/// Phone number associated with the token
pub phone: Option<String>,
/// URL to the user's avatar
pub avatar_url: Option<String>,
/// Additional message about the validation
pub message: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct ValidateTokenRequest {
/// Authentication token to validate
pub token: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct ChatPartner {
/// Username of the chat partner
pub username: String,
/// Phone number of the chat partner
pub phone: String,
/// URL to the partner's avatar
pub avatar_url: Option<String>,
/// Content of the last message
pub last_message: Option<String>,
/// Timestamp of the last message
pub last_message_time: Option<i64>,
/// Number of unread messages
pub unread_count: i64,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct SearchQuery {
/// Search query string
pub q: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct MarkReadRequest {
/// User who sent the messages
pub from_user: String,
/// User who received the messages
pub to_user: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct DeleteMessageRequest {
/// ID of the message to delete
pub message_id: String,
/// Whether to delete for everyone
pub for_everyone: bool,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct DeleteMessageResponse {
/// Indicates if the deletion was successful
pub success: bool,
/// Message describing the result
pub message: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct UploadAvatarResponse {
/// Indicates if the upload was successful
pub success: bool,
/// URL to the uploaded avatar
pub avatar_url: Option<String>,
/// Message describing the result
pub message: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct UserProfile {
/// Username of the profile
pub username: String,
/// Phone number of the profile
pub phone: String,
/// URL to the user's avatar
pub avatar_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub enum MessageType {
/// Text message
#[serde(rename = "text")]
Text,
/// File attachment
#[serde(rename = "file")]
File,
/// Photo attachment
#[serde(rename = "photo")]
Photo,
/// Video attachment
#[serde(rename = "video")]
Video,
/// Audio attachment
#[serde(rename = "audio")]
Audio,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ChatMessage {
/// Unique identifier for the message
pub id: String,
/// User who sent the message
pub from_user: String,
/// User who received the message
pub to_user: String,
/// Content of the message
pub content: String, // Store serialized content as JSON string
/// Timestamp of when the message was sent
pub timestamp: i64,
/// Type of the message
pub message_type: MessageType,
}
// impl ChatMessage {
// pub fn new_text(from_user: String, to_user: String, text: String) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: text,
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Text,
// }
// }
// pub fn new_file(from_user: String, to_user: String, file_info: FileInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&file_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::File,
// }
// }
// pub fn new_photo(from_user: String, to_user: String, photo_info: PhotoInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&photo_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Photo,
// }
// }
// pub fn new_video(from_user: String, to_user: String, video_info: VideoInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&video_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Video,
// }
// }
// pub fn new_audio(from_user: String, to_user: String, audio_info: AudioInfo) -> Self {
// ChatMessage {
// id: uuid::Uuid::new_v4().to_string(),
// from_user,
// to_user,
// content: serde_json::to_string(&audio_info).unwrap_or_default(),
// timestamp: chrono::Utc::now().timestamp(),
// message_type: MessageType::Audio,
// }
// }
// pub fn get_content_as_text(&self) -> Option<String> {
// match self.message_type {
// MessageType::Text => Some(self.content.clone()),
// _ => None,
// }
// }
// pub fn get_content_as_file(&self) -> Option<FileInfo> {
// if matches!(self.message_type, MessageType::File) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_photo(&self) -> Option<PhotoInfo> {
// if matches!(self.message_type, MessageType::Photo) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_video(&self) -> Option<VideoInfo> {
// if matches!(self.message_type, MessageType::Video) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// pub fn get_content_as_audio(&self) -> Option<AudioInfo> {
// if matches!(self.message_type, MessageType::Audio) {
// serde_json::from_str(&self.content).ok()
// } else {
// None
// }
// }
// }
// #[derive(Debug, Serialize, Deserialize, Clone)]
// pub enum MessageContent {
// #[serde(rename = "text")]
// Text(String),
// #[serde(rename = "file")]
// File(FileInfo),
// #[serde(rename = "photo")]
// Photo(PhotoInfo),
// #[serde(rename = "video")]
// Video(VideoInfo),
// }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct FileInfo {
/// Original filename
pub filename: String,
/// Path to the stored file
pub filepath: String,
/// Size of the file in bytes
pub size: u64,
/// MIME type of the file
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct PhotoInfo {
/// Original filename
pub filename: String,
/// Path to the stored photo
pub filepath: String,
/// Size of the photo in bytes
pub size: u64,
/// Width of the photo in pixels
pub width: u32,
/// Height of the photo in pixels
pub height: u32,
/// MIME type of the photo
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct VideoInfo {
/// Original filename
pub filename: String,
/// Path to the stored video
pub filepath: String,
/// Size of the video in bytes
pub size: u64,
/// Duration of the video in seconds
pub duration: Option<f64>, // in seconds
/// MIME type of the video
pub mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AudioInfo {
/// Original filename
pub filename: String,
/// Path to the stored audio
pub filepath: String,
/// Size of the audio in bytes
pub size: u64,
/// Duration of the audio in seconds
pub duration: Option<f64>, // in seconds
/// MIME type of the audio
pub mime_type: String,
}
// WebSocket message types
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct WsAuthMessage {
/// Type of the message (should be "auth")
#[schema(example = "auth")]
pub r#type: String,
/// Authentication token
#[schema(example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")]
pub token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct WsChatMessage {
/// Unique identifier for the message
#[schema(example = "123e4567-e89b-12d3-a456-426614174000")]
pub id: String,
/// User who sent the message
#[schema(example = "john_doe")]
pub from_user: String,
/// User who received the message
#[schema(example = "jane_doe")]
pub to_user: String,
/// Content of the message
#[schema(example = "Hello, world!")]
pub content: String,
/// Timestamp of when the message was sent
#[schema(example = 1678886400)]
pub timestamp: i64,
pub message_type: String,
/// Type of the message
pub message_type: MessageType,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct WsSystemMessage {
/// Type of the system message
#[schema(example = "system")]
pub r#type: String,
/// Content of the system message
#[schema(example = "Connected successfully")]
pub content: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct WsErrorMessage {
/// Type of the error message
#[schema(example = "error")]
pub r#type: String,
/// Content of the error message
#[schema(example = "Invalid or expired token")]
pub content: String,
}
+74
View File
@@ -0,0 +1,74 @@
use crate::api;
use crate::model::*;
use crate::websocket_doc::WebSocketApiDoc;
use utoipa::OpenApi;
/// Chat messaging API
#[derive(OpenApi)]
#[openapi(
info(
title = "Chat Messaging API",
description = "A simple chat messaging API with user registration, login, and message handling.",
version = "1.0.0"
),
paths(
api::websock::websocket_handler,
api::login::login_handler,
api::register::register_handler,
api::security::validate_token_handler,
api::chats::get_chats_handler,
api::search::search_users_handler,
api::messages::mark_read_handler,
api::messages::delete::delete_message_handler,
api::profile::get::profile_handler,
api::profile::avatar::upload_avatar_handler,
api::upload::upload_file_handler
),
components(
schemas(
AuthResponse,
RegisterRequest,
LoginRequest,
ValidateTokenResponse,
ValidateTokenRequest,
ChatPartner,
SearchQuery,
MarkReadRequest,
DeleteMessageRequest,
DeleteMessageResponse,
UploadAvatarResponse,
UserProfile,
MessageType,
ChatMessage,
FileInfo,
PhotoInfo,
VideoInfo,
AudioInfo,
WsAuthMessage,
WsChatMessage,
WsSystemMessage,
WsErrorMessage
)
),
modifiers(&SecurityAddon, &WebSocketApiDoc)
)]
pub struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
+20 -2
View File
@@ -121,13 +121,23 @@ pub async fn get_message_history(
let mut messages = Vec::new();
for row in rows {
let message_type_str: String = row.get("message_type");
let message_type: model::MessageType = match message_type_str.as_str() {
"text" => model::MessageType::Text,
"file" => model::MessageType::File,
"photo" => model::MessageType::Photo,
"video" => model::MessageType::Video,
"audio" => model::MessageType::Audio,
_ => model::MessageType::Text, // Default fallback
};
let message = model::ChatMessage {
id: row.get("id"),
from_user: row.get("from_user"),
to_user: row.get("to_user"),
content: row.get("content"),
timestamp: row.get("timestamp"),
message_type: row.get("message_type"),
message_type,
};
messages.push(message);
}
@@ -148,6 +158,14 @@ pub async fn get_message_history(
// }
pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result<(), sqlx::Error> {
let message_type_str = match msg.message_type {
model::MessageType::Text => "text",
model::MessageType::File => "file",
model::MessageType::Photo => "photo",
model::MessageType::Video => "video",
model::MessageType::Audio => "audio",
};
sqlx::query(
"INSERT OR REPLACE INTO messages (id, from_user, to_user, content, timestamp, message_type) VALUES (?, ?, ?, ?, ?, ?)",
)
@@ -156,7 +174,7 @@ pub async fn save_message(pool: &SqlitePool, msg: &model::ChatMessage) -> Result
.bind(&msg.to_user)
.bind(&msg.content)
.bind(msg.timestamp)
.bind(&msg.message_type)
.bind(message_type_str)
.execute(pool)
.await?;
+28
View File
@@ -0,0 +1,28 @@
use utoipa::Modify;
/// Расширенная документация для WebSocket API
pub struct WebSocketApiDoc;
impl Modify for WebSocketApiDoc {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
// Обновляем описание, добавляя информацию о WebSocket
let current_description = openapi.info.description.clone().unwrap_or_default();
let extended_description = format!(
"{}\n\n## WebSocket API\n\n### Connection Flow\n1. Connect to `/ws`\n2. Send auth message: `{{\"type\": \"auth\", \"token\": \"your_jwt\"}}`\n3. Receive system/error message\n4. Send/receive chat messages\n\n### Message Types:\n- **Auth**: `{{\"type\": \"auth\", \"token\": \"...\"}}`\n- **Chat**: `{{\"id\": \"...\", \"from_user\": \"...\", \"to_user\": \"...\", \"content\": \"...\", \"timestamp\": 12345, \"message_type\": \"text\"}}`\n- **System**: `{{\"type\": \"system\", \"content\": \"...\"}}`\n- **Error**: `{{\"type\": \"error\", \"content\": \"...\"}}`",
current_description
);
openapi.info.description = Some(extended_description);
// Добавляем тег для WebSocket
let websocket_tag = utoipa::openapi::tag::TagBuilder::new()
.name("WebSocket")
.description(Some("Real-time chat messaging via WebSocket"))
.build();
if let Some(tags) = &mut openapi.tags {
tags.push(websocket_tag);
} else {
openapi.tags = Some(vec![websocket_tag]);
}
}
}
+248 -22
View File
@@ -36,14 +36,14 @@
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--bg-primary);
height: 100vh;
overflow: hidden;
transition: background 0.3s ease;
}
[data-theme="dark"] body {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
background: var(--bg-primary);
}
/* Auth Container */
@@ -241,9 +241,11 @@ body {
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
height: calc(100vh-20px);
transition: transform 0.3s ease;
border-right: 1px solid var(--border-color);
border-radius: 20px;
margin: 10px;
}
.chats-header {
@@ -310,11 +312,13 @@ body {
.chat-item {
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
/*border-bottom: 1px solid var(--border-color);*/
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
border-radius: 15px;
margin: 5px;
}
.chat-item:hover {
@@ -364,13 +368,13 @@ body {
.chat-phone {
font-size: 11px;
color: var(--text-secondary);
/*color: var(--text-secondary);*/
margin-bottom: 3px;
}
.chat-last-message {
font-size: 12px;
color: var(--text-secondary);
/*color: var(--text-secondary);*/
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
@@ -387,7 +391,7 @@ body {
.chat-time {
font-size: 10px;
color: var(--text-secondary);
/*color: var(--text-secondary);*/
margin-bottom: 5px;
}
@@ -411,6 +415,7 @@ body {
background: var(--bg-primary);
height: 100vh;
position: relative;
margin-right: 10px;
}
.chat-header {
@@ -566,10 +571,14 @@ body {
.chat-input-container {
position: sticky;
bottom: 0;
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
padding: 15px;
/*background: var(--bg-primary);*/
/*border-top: 1px solid var(--border-color);*/
/*padding: 15px;*/
z-index: 10;
background: #212121;
padding: 2px;
border-radius: 25px;
margin-bottom: 10px;
}
.chat-input-area {
@@ -577,17 +586,20 @@ body {
gap: 10px;
align-items: flex-end;
max-height: 120px;
/*background: #212121;*/
}
.message-input {
flex: 1;
border: none;
padding: 12px 15px;
border: 2px solid var(--border-color);
/*border: 2px solid var(--border-color);*/
border-radius: 25px;
outline: none;
font-size: 14px;
transition: border-color 0.3s ease;
background: var(--bg-primary);
/*background: var(--bg-primary);*/
background: #212121;
color: var(--text-primary);
resize: none;
min-height: 44px;
@@ -597,16 +609,13 @@ body {
}
.message-input:focus {
border-color: var(--primary-color);
/*border-color: var(--primary-color);*/
}
.send-button {
padding: 12px 20px;
background: linear-gradient(
135deg,
var(--primary-color),
var(--primary-hover)
);
position: relative;
padding: 0 28px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 25px;
@@ -619,8 +628,13 @@ body {
}
.send-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
/*background: hsl(
from var(--primary-color) calc(h - 2%) calc(s - 2%) calc(l - 2%)
);*/
/*transform: translateY(-2px);*/
/*box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);*/
/*background-opacity: 0.8;*/
background: color-mix(in srgb, var(--primary-color) 70%, transparent);
}
.send-button:disabled {
@@ -662,6 +676,53 @@ body {
box-shadow: none;
}
.record-button {
padding: 12px 20px;
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
flex-shrink: 0;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.record-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(231, 76, 60, 0.4);
}
.record-button:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.record-button.recording {
background: linear-gradient(135deg, #ff6b6b, #ff5252);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(231, 76, 60, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0);
}
}
.system-message {
text-align: center;
color: var(--text-secondary);
@@ -1017,6 +1078,7 @@ body {
.chat-panel {
width: 100%;
height: 100vh;
margin-right: 10px;
}
.mobile-header {
@@ -1170,7 +1232,7 @@ body {
/* Safe area support for notches */
@supports (padding: max(0px)) {
.chat-input-container {
padding-bottom: max(15px, env(safe-area-inset-bottom));
/*padding-bottom: max(15px, env(safe-area-inset-bottom));*/
}
@media (max-width: 768px) {
@@ -1285,3 +1347,167 @@ body.modal-open {
.modal-btn-delete-for-me:hover {
background: var(--primary-hover);
}
/* File message styles */
.file-message {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 8px;
max-width: 300px;
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
font-size: 13px;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size,
.file-type {
font-size: 11px;
color: var(--text-secondary);
}
.download-btn {
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-top: 5px;
}
.download-btn:hover {
background: var(--primary-hover);
}
/* Media message styles */
.media-message {
display: flex;
flex-direction: column;
gap: 5px;
max-width: 300px;
}
.message-image {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
cursor: pointer;
object-fit: cover;
}
.message-video {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
}
.message-audio {
max-width: 100%;
border-radius: 8px;
}
/* Image viewer modal */
.image-viewer-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.image-viewer-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.image-viewer-large {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.close-image-viewer {
position: absolute;
top: -40px;
right: 0;
color: white;
font-size: 35px;
font-weight: bold;
cursor: pointer;
z-index: 2001;
}
.close-image-viewer:hover {
color: #ccc;
}
/* Upload progress styles */
.upload-progress {
padding: 10px;
margin: 5px 0;
background: var(--bg-tertiary);
border-radius: 10px;
align-self: flex-start;
max-width: 85%;
}
.progress-text {
font-size: 12px;
margin-bottom: 5px;
color: var(--text-secondary);
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
width: 0%;
transition: width 0.3s ease;
animation: progressAnimation 2s infinite;
}
@keyframes progressAnimation {
0% {
width: 0%;
}
50% {
width: 100%;
}
100% {
width: 0%;
}
}
+54 -2
View File
@@ -194,14 +194,65 @@
disabled
rows="1"
></textarea>
<button class="add-button">&plus;</button>
<input
type="file"
id="fileInput"
class="file-input"
style="display: none"
/>
<button
class="add-button"
onclick="
document.getElementById('fileInput').click()
"
>
&plus;
</button>
<button
id="recordVoiceBtn"
class="record-button"
title="Record voice message"
onclick="toggleVoiceRecording()"
>
●
</button>
<button
id="recordVideoBtn"
class="record-button"
title="Record video message"
onclick="toggleVideoRecording()"
>
◉
</button>
<button
onclick="sendMessage()"
class="send-button"
id="sendButton"
disabled
>
Send
<svg
style="
position: absolute;
left: 0;
right: 0;
top: 6px;
/*bottom: 5px;*/
"
xmlns="http://www.w3.org/2000/svg"
width="55"
height="34"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 14l11 -11" />
<path
d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"
/>
</svg>
</button>
</div>
</div>
@@ -344,5 +395,6 @@
<script src="js/deletemodal.js"></script>
<script src="js/search.js"></script>
<script src="js/avatar.js"></script>
<script src="js/recording.js"></script>
</body>
</html>
+276 -1
View File
@@ -142,7 +142,67 @@ function displayMessage(msg, scrollToBottom) {
const contentDiv = document.createElement("div");
contentDiv.className = "message-content";
contentDiv.textContent = msg.content;
// Handle different message types
if (msg.message_type === "text") {
contentDiv.textContent = msg.content;
} else if (msg.message_type === "file") {
const fileInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="file-message">
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">${fileInfo.filename}</div>
<div class="file-size">${formatFileSize(fileInfo.size)}</div>
<div class="file-type">${fileInfo.mime_type}</div>
</div>
<a href="${fileInfo.filepath}" download="${fileInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "photo") {
const photoInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<img src="${photoInfo.filepath}" alt="Photo" class="message-image" onclick="viewImage('${photoInfo.filepath}')"/>
<div class="file-info">
<div class="file-name">${photoInfo.filename}</div>
<div class="file-size">${formatFileSize(photoInfo.size)}</div>
</div>
<a href="${photoInfo.filepath}" download="${photoInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "video") {
const videoInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<video controls class="message-video">
<source src="${videoInfo.filepath}" type="${videoInfo.mime_type}">
Your browser does not support the video tag.
</video>
<div class="file-info">
<div class="file-name">${videoInfo.filename}</div>
<div class="file-size">${formatFileSize(videoInfo.size)}</div>
</div>
<a href="${videoInfo.filepath}" download="${videoInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
} else if (msg.message_type === "audio") {
const audioInfo = JSON.parse(msg.content);
contentDiv.innerHTML = `
<div class="media-message">
<audio controls class="message-audio">
<source src="${audioInfo.filepath}" type="${audioInfo.mime_type}">
Your browser does not support the audio tag.
</audio>
<div class="file-info">
<div class="file-name">${audioInfo.filename}</div>
<div class="file-size">${formatFileSize(audioInfo.size)}</div>
</div>
<a href="${audioInfo.filepath}" download="${audioInfo.filename}" class="download-btn">⬇️ Download</a>
</div>
`;
}
messageDiv.appendChild(contentDiv);
const timeDiv = document.createElement("div");
@@ -182,6 +242,40 @@ function displayMessage(msg, scrollToBottom) {
}
}
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function viewImage(imageUrl) {
// Create a modal to view the image
const modal = document.createElement("div");
modal.className = "image-viewer-modal";
modal.innerHTML = `
<div class="image-viewer-content">
<span class="close-image-viewer">&times;</span>
<img src="${imageUrl}" alt="Full size image" class="image-viewer-large">
</div>
`;
document.body.appendChild(modal);
// Close modal when clicking on close button
modal.querySelector(".close-image-viewer").onclick = function () {
document.body.removeChild(modal);
};
// Close modal when clicking outside the image
modal.onclick = function (event) {
if (event.target === modal) {
document.body.removeChild(modal);
}
};
}
function addSystemMessage(content) {
const chatMessages = document.getElementById("chatMessages");
@@ -234,6 +328,163 @@ function sendMessage() {
setTimeout(() => loadChats(), 100);
}
// Queue to handle file uploads sequentially
let uploadQueue = [];
let isUploading = false;
// Function to process the upload queue
async function processUploadQueue() {
if (isUploading || uploadQueue.length === 0) {
return;
}
isUploading = true;
const file = uploadQueue.shift(); // Get the first file in the queue
try {
await sendFileMessageInternal(file);
} catch (error) {
console.error("Upload failed for file:", error);
} finally {
isUploading = false;
// Process next file in queue if available
setTimeout(processUploadQueue, 100); // Small delay to allow server to settle
}
}
// Internal function to handle the actual file upload
async function sendFileMessageInternal(file) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showAlert("Please connect to the server first", "error");
return;
}
if (!currentRecipient) {
showAlert("Please select a user to chat with", "error");
return;
}
// Show upload progress
const chatMessages = document.getElementById("chatMessages");
const progressDiv = document.createElement("div");
progressDiv.className = "upload-progress";
progressDiv.innerHTML = `<div class="progress-text">Uploading ${file.name}</div><div class="progress-bar"><div class="progress-fill"></div></div>`;
chatMessages.appendChild(progressDiv);
try {
// Create FormData for file upload
const formData = new FormData();
formData.append("file", file);
// Upload file to server with better error handling
const response = await fetch("/api/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${currentToken}`,
},
body: formData,
}).catch((error) => {
console.error("Network error during upload:", error);
throw new Error("Network error during upload. Please try again.");
});
// Remove progress indicator
progressDiv.remove();
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
// Create message object based on file type
let message;
let messageType;
if (result.file_type === "photo") {
messageType = "photo";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else if (result.file_type === "video") {
messageType = "video";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else if (result.file_type === "audio") {
messageType = "audio";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
} else {
messageType = "file";
message = {
id: generateId(),
from_user: currentUser,
to_user: currentRecipient,
content: JSON.stringify(result.info),
timestamp: Date.now(),
message_type: messageType,
};
}
console.log("Sending file message:", message);
ws.send(JSON.stringify(message));
setTimeout(() => loadChats(), 100);
} catch (error) {
// Remove progress indicator
progressDiv.remove();
console.error("Upload error:", error);
showAlert(`Failed to upload file: ${error.message}`, "error");
}
}
// Function to send a file message (adds to queue)
async function sendFileMessage(file) {
// Check if file is already in queue to prevent duplicates
const fileName = file.name;
const fileSize = file.size;
const isInQueue = uploadQueue.some(
(item) => item.name === fileName && item.size === fileSize,
);
if (isInQueue) {
console.log(
`File ${fileName} is already in upload queue, skipping duplicate.`,
);
return;
}
// Add file to upload queue
uploadQueue.push(file);
console.log(
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
);
// Process the queue
setTimeout(processUploadQueue, 0);
}
// Event listeners
document
.getElementById("messageInput")
@@ -243,3 +494,27 @@ document
sendMessage();
}
});
// Track if event listener is already attached to prevent duplicates
if (!window.fileInputListenerAttached) {
// File input event listener
document.getElementById("fileInput").addEventListener("change", function (e) {
const file = e.target.files[0];
if (file) {
// Add file to upload queue regardless of current upload status
// The queue system will handle the sequencing
uploadQueue.push(file);
console.log(
`File ${file.name} added to upload queue. Queue length: ${uploadQueue.length}`,
);
// Process the queue
setTimeout(processUploadQueue, 0);
// Reset the input so the same file can be selected again
e.target.value = "";
}
});
window.fileInputListenerAttached = true;
}
+162
View File
@@ -0,0 +1,162 @@
// Voice and video recording functionality
let mediaRecorder;
let recordedChunks = [];
let isRecording = false;
let recordingType = null; // 'voice' or 'video'
// Toggle voice recording
async function toggleVoiceRecording() {
if (isRecording) {
stopRecording();
} else {
await startVoiceRecording();
}
}
// Toggle video recording
async function toggleVideoRecording() {
if (isRecording) {
stopRecording();
} else {
await startVideoRecording();
}
}
// Start voice recording
async function startVoiceRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
recordedChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
// Create a temporary file object
const file = new File([blob], `voice_message_${Date.now()}.webm`, { type: 'audio/webm' });
// Send the voice message
await sendFileMessage(file);
// Stop the tracks
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
isRecording = true;
recordingType = 'voice';
// Update UI to show recording state
updateRecordingUI(true);
console.log('Voice recording started');
} catch (error) {
console.error('Error starting voice recording:', error);
showAlert('Microphone access denied. Please allow microphone access to record voice messages.', 'error');
}
}
// Start video recording
async function startVideoRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 } },
audio: true
});
mediaRecorder = new MediaRecorder(stream);
recordedChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
// Create a temporary file object
const file = new File([blob], `video_message_${Date.now()}.webm`, { type: 'video/webm' });
// Send the video message
await sendFileMessage(file);
// Stop the tracks
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
isRecording = true;
recordingType = 'video';
// Update UI to show recording state
updateRecordingUI(true);
console.log('Video recording started');
} catch (error) {
console.error('Error starting video recording:', error);
showAlert('Camera access denied. Please allow camera and microphone access to record video messages.', 'error');
}
}
// Stop recording
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
isRecording = false;
recordingType = null;
// Update UI to show stopped state
updateRecordingUI(false);
console.log('Recording stopped');
}
}
// Update UI based on recording state
function updateRecordingUI(isRecording) {
const voiceBtn = document.getElementById('recordVoiceBtn');
const videoBtn = document.getElementById('recordVideoBtn');
if (isRecording) {
// Add recording class to the active button
if (recordingType === 'voice') {
voiceBtn.classList.add('recording');
voiceBtn.textContent = '●'; // Solid circle to indicate recording
} else if (recordingType === 'video') {
videoBtn.classList.add('recording');
videoBtn.textContent = '●'; // Solid circle to indicate recording
}
} else {
// Remove recording class from both buttons
voiceBtn.classList.remove('recording');
videoBtn.classList.remove('recording');
// Reset button texts
voiceBtn.textContent = '●';
videoBtn.textContent = '◉';
}
}
// Handle window unload to clean up resources
window.addEventListener('beforeunload', () => {
if (isRecording) {
stopRecording();
}
});
// Also stop recording when leaving the page
window.addEventListener('pagehide', () => {
if (isRecording) {
stopRecording();
}
});