Files
ColdBreeze-chat-server-rust/src/api/admin_system.rs
T
Binbim 7d3333bbff
Release / build (aarch64, true, ubuntu-latest, sudo apt-get install -y rsync, stable, aarch64-unknown-linux-musl) (push) Has been cancelled
Release / build (arm-v7, true, ubuntu-latest, sudo apt-get install -y rsync, stable, armv7-unknown-linux-musleabihf) (push) Has been cancelled
Release / build (linux, ubuntu-latest, sudo apt-get install -y rsync, stable, x86_64-unknown-linux-musl) (push) Has been cancelled
Release / build (macos, macos-latest, brew install rsync, stable, x86_64-apple-darwin) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / add (aarch64, true, ubuntu-latest, stable, aarch64-unknown-linux-musl) (push) Has been cancelled
Release / add (arm-v7, true, ubuntu-latest, stable, armv7-unknown-linux-musleabihf) (push) Has been cancelled
Release / add (linux, ubuntu-latest, stable, x86_64-unknown-linux-musl) (push) Has been cancelled
Release / add (macos, macos-latest, stable, x86_64-apple-darwin) (push) Has been cancelled
feat: migrate to LiveKit, remove Agora/payment, add domain certs for Caddy
2026-07-04 07:15:41 +08:00

459 lines
13 KiB
Rust

use image::ImageFormat;
use poem::{error::InternalServerError, http::StatusCode, web::Data, Error, Result};
use poem_openapi::{
payload::{Binary, Json, PlainText},
types::Email,
ApiRequest, Object, OpenApi,
};
use serde::{Deserialize, Serialize};
use crate::{
api::{tags::ApiTags, token::Token, SmtpConfig, UserInfo},
config::Config,
create_user::{CreateUser, CreateUserBy},
server::create_random_str,
state::{send_mail, DynamicConfig, DynamicConfigEntry},
State,
};
/// Server-level common settings
#[derive(Debug, Object, Serialize, Deserialize, Default, Clone)]
pub struct SystemCommon {
pub show_user_online_status: Option<bool>,
pub webclient_auto_update: Option<bool>,
pub contact_verification_enable: Option<bool>,
pub chat_layout_mode: Option<String>,
pub max_file_expiry_mode: Option<String>,
pub only_admin_can_create_group: Option<bool>,
pub who_can_invite_users: Option<bool>,
pub ext_setting: Option<String>,
pub msg_smtp_notify_enable: Option<bool>,
pub msg_smtp_notify_delay_seconds: Option<i32>,
pub dm_enable: Option<bool>,
pub add_friend_enable: Option<bool>,
pub search_user_enable: Option<bool>,
}
impl DynamicConfig for SystemCommon {
type Instance = Self;
fn name() -> &'static str {
"system_common"
}
fn create_instance(self, _config: &Config) -> Self::Instance {
self
}
}
/// Server metrics
#[derive(Debug, Object)]
pub struct Metrics {
user_count: usize,
group_count: usize,
online_user_count: usize,
version: String,
}
/// Frontend url
#[derive(Debug, Object, Serialize, Deserialize, Default)]
pub struct FrontendUrlConfig {
pub url: Option<String>,
}
impl DynamicConfig for FrontendUrlConfig {
type Instance = Self;
fn name() -> &'static str {
"frontend-url"
}
fn create_instance(self, _config: &Config) -> Self::Instance {
self
}
}
/// Organization info
#[derive(Debug, Object, Serialize, Deserialize)]
pub struct OrganizationConfig {
name: String,
description: Option<String>,
}
impl DynamicConfig for OrganizationConfig {
type Instance = Self;
fn name() -> &'static str {
"organization"
}
fn create_instance(self, _config: &Config) -> Self::Instance {
self
}
}
impl Default for OrganizationConfig {
fn default() -> Self {
Self {
name: "unknown".to_string(),
description: None,
}
}
}
#[derive(ApiRequest)]
enum UploadLogoRequest {
#[oai(content_type = "image/png")]
Image(Binary<Vec<u8>>),
}
#[derive(Object)]
struct SendMailRequest {
to: String,
subject: String,
content: String,
}
#[derive(Object)]
struct CreateAdminRequest {
email: Email,
name: String,
password: String,
gender: i32,
}
pub struct ApiAdminSystem;
#[OpenApi(prefix_path = "/admin/system", tag = "ApiTags::AdminSystem")]
impl ApiAdminSystem {
/// Get the server version
#[oai(path = "/version", method = "get")]
async fn version(&self) -> PlainText<&'static str> {
PlainText(env!("CARGO_PKG_VERSION"))
}
/// Create administrator user
#[oai(path = "/create_admin", method = "post")]
async fn create_admin_user(
&self,
state: Data<&State>,
mut req: Json<CreateAdminRequest>,
) -> Result<Json<UserInfo>> {
if !state.cache.read().await.users.is_empty() {
return Err(poem::Error::from_status(StatusCode::FORBIDDEN));
}
req.email.0 = req.email.0.to_lowercase();
let (uid, user) = match state
.create_user(
CreateUser::new(
&req.name,
CreateUserBy::Password {
email: &req.email,
password: &req.password,
},
true,
)
.gender(req.gender),
)
.await
{
Ok(res) => res,
Err(_) => return Err(poem::Error::from_status(StatusCode::FORBIDDEN)),
};
// update user.is_admin
Ok(Json(user.api_user_info(uid)))
}
/// Returns `true` means that the server has been initialized
#[oai(path = "/initialized", method = "get")]
async fn initialized(&self, state: Data<&State>) -> Json<bool> {
let cache = state.cache.read().await;
Json(!cache.users.is_empty())
}
/// Get the system metrics
#[oai(path = "/metrics", method = "get")]
async fn get_metrics(&self, state: Data<&State>, token: Token) -> Result<Json<Metrics>> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let cache = state.cache.read().await;
Ok(Json(Metrics {
user_count: cache.users.iter().filter(|user| !user.1.is_guest).count(),
group_count: cache.groups.len(),
online_user_count: cache
.users
.values()
.filter(|user| !user.is_guest && user.is_online())
.count(),
version: env!("CARGO_PKG_VERSION").to_string(),
}))
}
/// Get system common settings
#[oai(path = "/common", method = "get")]
async fn get_system_common(&self, state: Data<&State>) -> Json<SystemCommon> {
match state.load_dynamic_config::<SystemCommon>().await {
Ok(entry) => Json(entry.config),
Err(_) => Json(SystemCommon::default()),
}
}
/// Update system common settings
#[oai(path = "/common", method = "put")]
async fn update_system_common(
&self,
state: Data<&State>,
token: Token,
req: Json<SystemCommon>,
) -> Result<()> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
state
.set_dynamic_config(DynamicConfigEntry {
enabled: true,
config: req.0,
})
.await?;
Ok(())
}
/// Get the organization info
#[oai(path = "/organization", method = "get")]
async fn get_organization(&self, state: Data<&State>) -> Result<Json<OrganizationConfig>> {
let entry = state.load_dynamic_config::<OrganizationConfig>().await?;
Ok(Json(entry.config))
}
/// Set the organization info
#[oai(path = "/organization", method = "post")]
async fn set_organization(
&self,
state: Data<&State>,
token: Token,
req: Json<OrganizationConfig>,
) -> Result<()> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
state
.set_dynamic_config(DynamicConfigEntry {
enabled: true,
config: req.0,
})
.await?;
Ok(())
}
/// Upload the organization logo
#[oai(path = "/organization/logo", method = "post")]
async fn upload_organization_logo(
&self,
state: Data<&State>,
token: Token,
logo: UploadLogoRequest,
) -> Result<()> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let UploadLogoRequest::Image(data) = logo;
let logo = image::load_from_memory(&data).map_err(InternalServerError)?;
let logo = logo.thumbnail(240, 240);
let path = state.config.system.data_dir.join("organization.png");
logo.save_with_format(path, ImageFormat::Png)
.map_err(InternalServerError)?;
Ok(())
}
/// Send email(only for test)
#[oai(path = "/send_mail", method = "post")]
async fn send_mail(
&self,
state: Data<&State>,
token: Token,
req: Json<SendMailRequest>,
) -> Result<()> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let smtp_config = state
.get_dynamic_config_instance::<SmtpConfig>()
.await
.ok_or_else(|| Error::from_status(StatusCode::SERVICE_UNAVAILABLE))?;
Ok(send_mail(&smtp_config, &req.to, &req.subject, &req.content).await?)
}
/// Get the secret for third-party authentication
#[oai(path = "/third_party_secret", method = "get")]
async fn third_party_secret(
&self,
state: Data<&State>,
token: Token,
) -> Result<PlainText<String>> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let key_config = state.key_config.read().await;
Ok(PlainText(key_config.third_party_secret.clone()))
}
/// Update third-party secret
#[oai(path = "/third_party_secret", method = "post")]
async fn update_third_party_secret(
&self,
state: Data<&State>,
token: Token,
) -> Result<PlainText<String>> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let mut key_config = state.key_config.write().await;
let new_third_party_secret = create_random_str(32);
key_config.third_party_secret = new_third_party_secret.clone();
let key_config_path = state.config.system.data_dir.join("key.json");
std::fs::write(
key_config_path,
serde_json::to_vec(&*key_config).map_err(InternalServerError)?,
)
.map_err(InternalServerError)?;
Ok(PlainText(new_third_party_secret))
}
/// Get the frontend url
#[oai(path = "/frontend_url", method = "get")]
async fn get_frontend_url(
&self,
state: Data<&State>,
token: Token,
) -> Result<PlainText<String>> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
Ok(PlainText(
state
.get_dynamic_config_instance::<FrontendUrlConfig>()
.await
.and_then(|config| config.url.clone())
.unwrap_or_default(),
))
}
/// Update the frontend url
#[oai(path = "/update_frontend_url", method = "post")]
async fn update_frontend_url(
&self,
state: Data<&State>,
token: Token,
frontend_url: PlainText<String>,
) -> Result<()> {
if !token.is_admin {
return Err(Error::from_status(StatusCode::FORBIDDEN));
}
let frontend_url = frontend_url.0.trim_end_matches('/');
let re = regex::Regex::new(r#"^https?://[\w\-\.]+(:\d+)?$"#).unwrap();
if !re.is_match(frontend_url) {
return Err(Error::from_string(
"Bad url format!",
StatusCode::BAD_REQUEST,
));
}
state
.set_dynamic_config(DynamicConfigEntry {
enabled: true,
config: FrontendUrlConfig {
url: Some(frontend_url.to_string()),
},
})
.await?;
Ok(())
}
}
#[test]
fn test_frontend_url() {
let re = regex::Regex::new(r#"^https?://[\w\-\.]+(:\d+)?$"#).unwrap();
assert!(re.is_match("http://1.2.3.4:4000"));
assert!(re.is_match("http://domain.com"));
assert!(re.is_match("http://domain.com:3000"));
assert!(re.is_match("https://domain.com:3000"));
assert!(re.is_match("http://127.0.0.1"));
assert!(re.is_match("http://127.0.0.1:3000"));
assert!(re.is_match("https://127.0.0.1:3000"));
assert!(!re.is_match("ftp://127.0.0.1:3000"));
}
#[test]
fn test_replace_config() {
let a = r#"frontend_url = "http://a.com/""#;
let re = regex::Regex::new(r#"frontend_url\s*=\s*".*?""#).unwrap();
let b = re.replace(a, format!(r#"frontend_url = "{}""#, "http://b.com/"));
assert_eq!(b, r#"frontend_url = "http://b.com/""#);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::test_harness::TestServer;
#[tokio::test]
async fn set_organization() {
let server = TestServer::new().await;
let admin_token = server.login_admin().await;
let resp = server.get("/api/admin/system/organization").send().await;
resp.assert_status_is_ok();
resp.assert_json(&json!({
"name": "unknown",
"description": null,
}))
.await;
server
.post("/api/admin/system/organization")
.header("X-API-Key", &admin_token)
.body_json(&json!({
"name": "abc",
"description": "def"
}))
.send()
.await
.assert_status_is_ok();
let resp = server.get("/api/admin/system/organization").send().await;
resp.assert_status_is_ok();
resp.assert_json(&json!({
"name": "abc",
"description": "def",
}))
.await;
}
#[tokio::test]
async fn test_update_frontend_url() {
let server = TestServer::new().await;
let admin_token = server.login_admin().await;
let resp = server
.post("/api/admin/system/update_frontend_url")
.header("X-API-Key", &admin_token)
.content_type("text/plain")
.body("http://1.2.3.4:4000")
.send()
.await;
resp.assert_status_is_ok();
}
}