872 lines
26 KiB
Rust
872 lines
26 KiB
Rust
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use cot::db::Database;
|
|
use cot::json::Json;
|
|
use cot::request::extractors::Path;
|
|
use cot::response::IntoResponse;
|
|
use cot::router::method::openapi::{api_delete, api_get, api_post};
|
|
use cot::router::{Route, Router};
|
|
use cot::session::Session;
|
|
use cot::{App, Body};
|
|
use qrcode::QrCode;
|
|
use qrcode::render::svg;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::config::AppConfig;
|
|
use crate::user::User;
|
|
use crate::{auth, telegram, vpn};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON error helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
|
json_error_typed(
|
|
status,
|
|
status.canonical_reason().unwrap_or("request_failed"),
|
|
status.canonical_reason().unwrap_or("Request failed"),
|
|
message,
|
|
"",
|
|
)
|
|
}
|
|
|
|
fn json_error_typed(
|
|
status: cot::http::StatusCode,
|
|
code: &str,
|
|
title: &str,
|
|
message: &str,
|
|
detail: &str,
|
|
) -> cot::response::Response {
|
|
let body = serde_json::json!({
|
|
"code": code,
|
|
"title": title,
|
|
"error": message,
|
|
"detail": detail,
|
|
});
|
|
cot::http::Response::builder()
|
|
.status(status)
|
|
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
|
.body(Body::fixed(body.to_string()))
|
|
.expect("valid response")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/me
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct MeResponse {
|
|
id: i64,
|
|
name: String,
|
|
role: String,
|
|
}
|
|
|
|
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
};
|
|
|
|
Json(MeResponse {
|
|
id: user.id,
|
|
name: user.name,
|
|
role: user.role.code().to_owned(),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// VPN client API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct VpnClientsResponse {
|
|
role: String,
|
|
clients: Vec<vpn::VpnClientView>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct CreateVpnClientRequest {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct MutateVpnClientResponse {
|
|
client: vpn::VpnClientView,
|
|
sync: vpn::SecretSyncResult,
|
|
notice: Option<ApiNotice>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct SetEnabledRequest {
|
|
enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct DeleteVpnClientResponse {
|
|
sync: vpn::SecretSyncResult,
|
|
notice: Option<ApiNotice>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
|
struct ApiNotice {
|
|
kind: String,
|
|
code: String,
|
|
title: String,
|
|
message: String,
|
|
detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct ClientServerConfigResponse {
|
|
endpoint_id: String,
|
|
endpoint_name: String,
|
|
endpoint: String,
|
|
config: String,
|
|
vpn_url: String,
|
|
qr_payload: String,
|
|
qr_svg: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct ClientConfigResponse {
|
|
id: i64,
|
|
name: String,
|
|
servers: Vec<ClientServerConfigResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct ClientPath {
|
|
id: i64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct TelegramLinkStatusResponse {
|
|
enabled: bool,
|
|
bot_username: String,
|
|
bot_url: String,
|
|
linked: bool,
|
|
declined: bool,
|
|
pending: bool,
|
|
pending_secret: Option<String>,
|
|
pending_expires_at: Option<i64>,
|
|
telegram_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, JsonSchema)]
|
|
struct TelegramLinkResponse {
|
|
status: TelegramLinkStatusResponse,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct TelegramWebAppLinkRequest {
|
|
init_data: String,
|
|
}
|
|
|
|
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
|
|
|
|
async fn vpn_clients_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
};
|
|
|
|
let clients = vpn::VpnClient::list_visible(&db, &user)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?;
|
|
let owner_map = owner_view_map(&db, &clients).await?;
|
|
let clients = clients
|
|
.into_iter()
|
|
.map(|client| client_view_with_owner(client, &owner_map))
|
|
.collect();
|
|
|
|
Json(VpnClientsResponse {
|
|
role: user.role.code().to_owned(),
|
|
clients,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn vpn_status_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
}
|
|
};
|
|
tracing::debug!(user_id = user.id, "VPN rollout status requested");
|
|
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let mut status = match vpn::read_rollout_status_from_kubernetes(&config).await {
|
|
Ok(status) => status,
|
|
Err(e) => {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
"vpn_status_unavailable",
|
|
"Server status unavailable",
|
|
"Could not load VPN server status.",
|
|
&e,
|
|
));
|
|
}
|
|
};
|
|
if user.role != auth::Role::Admin {
|
|
let disabled = vpn::disabled_endpoint_names(&config);
|
|
status.pods.retain(|pod| !disabled.contains(&pod.node_name));
|
|
}
|
|
|
|
Json(status).into_response()
|
|
}
|
|
|
|
async fn telegram_link_status_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match api_user_record(&session, &db).await? {
|
|
Ok(user) => user,
|
|
Err(response) => return Ok(response),
|
|
};
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
|
|
Json(telegram_status_response(&config, &user)).into_response()
|
|
}
|
|
|
|
async fn telegram_link_webapp_handler(
|
|
session: Session,
|
|
db: Database,
|
|
Json(request): Json<TelegramWebAppLinkRequest>,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let mut user = match api_user_record(&session, &db).await? {
|
|
Ok(user) => user,
|
|
Err(response) => return Ok(response),
|
|
};
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
if !config.telegram_bot_enabled {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::CONFLICT,
|
|
"telegram_disabled",
|
|
"Telegram is disabled",
|
|
"Telegram integration is disabled by the administrator.",
|
|
"",
|
|
));
|
|
}
|
|
|
|
let telegram_user = match telegram::validate_web_app_init_data(
|
|
&request.init_data,
|
|
&config.telegram_bot_token,
|
|
TELEGRAM_INIT_DATA_MAX_AGE,
|
|
) {
|
|
Ok(user) => user,
|
|
Err(e) => {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::BAD_REQUEST,
|
|
"telegram_webapp_auth_failed",
|
|
"Telegram verification failed",
|
|
"Could not verify Telegram Web App data.",
|
|
&e.to_string(),
|
|
));
|
|
}
|
|
};
|
|
let telegram_id = telegram_user.id.to_string();
|
|
if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? {
|
|
return Ok(response);
|
|
}
|
|
|
|
Json(TelegramLinkResponse {
|
|
status: telegram_status_response(&config, &user),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn telegram_link_start_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let mut user = match api_user_record(&session, &db).await? {
|
|
Ok(user) => user,
|
|
Err(response) => return Ok(response),
|
|
};
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
if !config.telegram_bot_enabled {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::CONFLICT,
|
|
"telegram_disabled",
|
|
"Telegram is disabled",
|
|
"Telegram integration is disabled by the administrator.",
|
|
"",
|
|
));
|
|
}
|
|
|
|
let code = create_unique_telegram_link_code(&db).await?;
|
|
let now = telegram::now_unix_seconds();
|
|
user.set_telegram_link_code(&db, Some(&code), Some(now))
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?;
|
|
if user.telegram_id() == Some("") {
|
|
user.set_telegram_id(&db, None).await.map_err(|e| {
|
|
cot::Error::internal(format!("failed to reset Telegram preference: {e}"))
|
|
})?;
|
|
}
|
|
|
|
Json(TelegramLinkResponse {
|
|
status: telegram_status_response(&config, &user),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn telegram_link_decline_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let mut user = match api_user_record(&session, &db).await? {
|
|
Ok(user) => user,
|
|
Err(response) => return Ok(response),
|
|
};
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
if !config.telegram_bot_enabled {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::CONFLICT,
|
|
"telegram_disabled",
|
|
"Telegram is disabled",
|
|
"Telegram integration is disabled by the administrator.",
|
|
"",
|
|
));
|
|
}
|
|
|
|
user.decline_telegram_link(&db)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?;
|
|
|
|
Json(TelegramLinkResponse {
|
|
status: telegram_status_response(&config, &user),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn telegram_link_unlink_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let mut user = match api_user_record(&session, &db).await? {
|
|
Ok(user) => user,
|
|
Err(response) => return Ok(response),
|
|
};
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
|
|
user.clear_telegram_link(&db)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to clear Telegram link: {e}")))?;
|
|
|
|
Json(TelegramLinkResponse {
|
|
status: telegram_status_response(&config, &user),
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn create_vpn_client_handler(
|
|
session: Session,
|
|
db: Database,
|
|
Json(request): Json<CreateVpnClientRequest>,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
}
|
|
};
|
|
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let client = match vpn::VpnClient::create_for_owner(
|
|
&db,
|
|
user.id,
|
|
&request.name,
|
|
&config.vpn_client_cidr,
|
|
)
|
|
.await
|
|
{
|
|
Ok(client) => client,
|
|
Err(e) => {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::BAD_REQUEST,
|
|
"client_create_failed",
|
|
"Could not create key",
|
|
"The key was not created.",
|
|
&e.to_string(),
|
|
));
|
|
}
|
|
};
|
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
|
|
|
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
|
Json(MutateVpnClientResponse {
|
|
client: client_view_with_owner(client, &owner_map),
|
|
sync,
|
|
notice,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn set_vpn_client_enabled_handler(
|
|
session: Session,
|
|
db: Database,
|
|
Path(path): Path<ClientPath>,
|
|
Json(request): Json<SetEnabledRequest>,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
}
|
|
};
|
|
|
|
let Some(mut client) = vpn::VpnClient::get_visible(&db, &user, path.id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))?
|
|
else {
|
|
return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found"));
|
|
};
|
|
|
|
client
|
|
.set_enabled(&db, request.enabled)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
|
|
|
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
|
Json(MutateVpnClientResponse {
|
|
client: client_view_with_owner(client, &owner_map),
|
|
sync,
|
|
notice,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn delete_vpn_client_handler(
|
|
session: Session,
|
|
db: Database,
|
|
Path(path): Path<ClientPath>,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
}
|
|
};
|
|
|
|
let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))?
|
|
else {
|
|
return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found"));
|
|
};
|
|
|
|
vpn::VpnClient::delete_by_id(&db, client.id_val())
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
|
|
|
Json(DeleteVpnClientResponse { sync, notice }).into_response()
|
|
}
|
|
|
|
async fn vpn_client_config_handler(
|
|
session: Session,
|
|
db: Database,
|
|
Path(path): Path<ClientPath>,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
));
|
|
}
|
|
};
|
|
|
|
let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))?
|
|
else {
|
|
return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found"));
|
|
};
|
|
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let runtime = match vpn::read_runtime_from_kubernetes(&config).await {
|
|
Ok(runtime) => runtime,
|
|
Err(e) => {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
"vpn_runtime_unavailable",
|
|
"VPN servers are unavailable",
|
|
"Could not load VPN server list.",
|
|
&e,
|
|
));
|
|
}
|
|
};
|
|
let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config);
|
|
if endpoints.is_empty() {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::CONFLICT,
|
|
"no VPN endpoints are enabled",
|
|
));
|
|
};
|
|
|
|
let mut servers = Vec::with_capacity(endpoints.len());
|
|
for endpoint in endpoints {
|
|
let config_text = vpn::render_client_config(
|
|
&client,
|
|
&runtime.server_public_key,
|
|
&endpoint.endpoint,
|
|
&config,
|
|
);
|
|
let qr_payload = vpn::render_vpn_payload(
|
|
&client,
|
|
&runtime.server_public_key,
|
|
&endpoint.display_name,
|
|
&endpoint.endpoint,
|
|
&config,
|
|
)
|
|
.map_err(|e| cot::Error::internal(format!("failed to render VPN QR payload: {e}")))?;
|
|
let vpn_url = vpn::render_vpn_url(&qr_payload);
|
|
|
|
servers.push(ClientServerConfigResponse {
|
|
endpoint_id: endpoint.name,
|
|
endpoint_name: endpoint.display_name,
|
|
endpoint: endpoint.endpoint,
|
|
config: config_text,
|
|
qr_svg: render_qr_svg(&qr_payload)?,
|
|
qr_payload,
|
|
vpn_url,
|
|
});
|
|
}
|
|
|
|
Json(ClientConfigResponse {
|
|
id: client.id_val(),
|
|
name: client.name_str().to_owned(),
|
|
servers,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct OwnerView {
|
|
username: String,
|
|
display_name: String,
|
|
}
|
|
|
|
async fn owner_view_map(
|
|
db: &Database,
|
|
clients: &[vpn::VpnClient],
|
|
) -> cot::Result<HashMap<i64, OwnerView>> {
|
|
let owner_ids = clients
|
|
.iter()
|
|
.map(vpn::VpnClient::owner_user_id)
|
|
.collect::<std::collections::HashSet<_>>();
|
|
let users = User::list_all(db)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?;
|
|
Ok(users
|
|
.into_iter()
|
|
.filter(|user| owner_ids.contains(&user.id_val()))
|
|
.map(|user| {
|
|
(
|
|
user.id_val(),
|
|
OwnerView {
|
|
username: user.username_str().to_owned(),
|
|
display_name: user.display_name_str(),
|
|
},
|
|
)
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn client_view_with_owner(
|
|
client: vpn::VpnClient,
|
|
owner_map: &HashMap<i64, OwnerView>,
|
|
) -> vpn::VpnClientView {
|
|
let mut view = client.view();
|
|
if let Some(owner) = owner_map.get(&view.owner_user_id) {
|
|
view.owner_username = owner.username.clone();
|
|
view.owner_display_name = owner.display_name.clone();
|
|
}
|
|
view
|
|
}
|
|
|
|
async fn api_user_record(
|
|
session: &Session,
|
|
db: &Database,
|
|
) -> cot::Result<Result<User, cot::response::Response>> {
|
|
let Some(auth_user) = auth::get_session_user(session, db).await else {
|
|
return Ok(Err(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
)));
|
|
};
|
|
let Some(user) = User::get_by_id(db, auth_user.id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))?
|
|
else {
|
|
return Ok(Err(json_error(
|
|
cot::http::StatusCode::UNAUTHORIZED,
|
|
"not authenticated",
|
|
)));
|
|
};
|
|
Ok(Ok(user))
|
|
}
|
|
|
|
fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStatusResponse {
|
|
let bot_username = normalized_telegram_bot_username(&config.telegram_bot_username);
|
|
let telegram_id = user.telegram_id().map(str::to_owned);
|
|
let linked = telegram_id
|
|
.as_deref()
|
|
.is_some_and(|value| !value.is_empty());
|
|
let declined = telegram_id.as_deref() == Some("");
|
|
let pending = user.telegram_link_code().is_some()
|
|
&& telegram::link_code_is_active(user.telegram_link_code_created_at());
|
|
let pending_secret = pending
|
|
.then(|| user.telegram_link_code().map(str::to_owned))
|
|
.flatten();
|
|
let pending_expires_at = pending
|
|
.then(|| {
|
|
user.telegram_link_code_created_at()
|
|
.map(telegram::link_code_expires_at)
|
|
})
|
|
.flatten();
|
|
|
|
TelegramLinkStatusResponse {
|
|
enabled: config.telegram_bot_enabled,
|
|
bot_url: telegram_bot_url(&bot_username),
|
|
bot_username,
|
|
linked,
|
|
declined,
|
|
pending,
|
|
pending_secret,
|
|
pending_expires_at,
|
|
telegram_id: linked.then_some(telegram_id).flatten(),
|
|
}
|
|
}
|
|
|
|
fn normalized_telegram_bot_username(username: &str) -> String {
|
|
username.trim().trim_start_matches('@').trim().to_owned()
|
|
}
|
|
|
|
fn telegram_bot_url(username: &str) -> String {
|
|
if username.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("https://t.me/{username}")
|
|
}
|
|
}
|
|
|
|
async fn create_unique_telegram_link_code(db: &Database) -> cot::Result<String> {
|
|
for _ in 0..8 {
|
|
let code = telegram::generate_link_code().map_err(cot::Error::internal)?;
|
|
let existing = User::get_by_telegram_link_code(db, &code)
|
|
.await
|
|
.map_err(|e| {
|
|
cot::Error::internal(format!("failed to check Telegram link code: {e}"))
|
|
})?;
|
|
if existing.is_none() {
|
|
return Ok(code);
|
|
}
|
|
}
|
|
Err(cot::Error::internal(
|
|
"failed to generate unique Telegram link code",
|
|
))
|
|
}
|
|
|
|
async fn link_telegram_id(
|
|
db: &Database,
|
|
user: &mut User,
|
|
telegram_id: &str,
|
|
) -> cot::Result<Option<cot::response::Response>> {
|
|
if let Some(existing) = User::get_by_telegram_id(db, telegram_id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to check Telegram ID: {e}")))?
|
|
{
|
|
if existing.id_val() != user.id_val() {
|
|
return Ok(Some(json_error_typed(
|
|
cot::http::StatusCode::CONFLICT,
|
|
"telegram_id_taken",
|
|
"Telegram ID is already linked",
|
|
"This Telegram ID is already connected to another account.",
|
|
"",
|
|
)));
|
|
}
|
|
}
|
|
|
|
user.complete_telegram_link(db, telegram_id)
|
|
.await
|
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?;
|
|
Ok(None)
|
|
}
|
|
|
|
async fn sync_after_client_mutation(
|
|
db: &Database,
|
|
config: &AppConfig,
|
|
) -> (vpn::SecretSyncResult, Option<ApiNotice>) {
|
|
match vpn::sync_from_database(db, config).await {
|
|
Ok(sync) => (sync, None),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "client data changed but Secret sync failed");
|
|
(
|
|
vpn::SecretSyncResult {
|
|
changed: false,
|
|
message: "client data saved; Secret sync failed".to_owned(),
|
|
},
|
|
Some(ApiNotice {
|
|
kind: "warning".to_owned(),
|
|
code: "secret_sync_failed".to_owned(),
|
|
title: "Saved locally".to_owned(),
|
|
message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(),
|
|
detail: e,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_qr_svg(value: &str) -> cot::Result<String> {
|
|
let code = QrCode::new(value.as_bytes())
|
|
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
|
|
Ok(code
|
|
.render::<svg::Color>()
|
|
.min_dimensions(256, 256)
|
|
.dark_color(svg::Color("#17202a"))
|
|
.light_color(svg::Color("#ffffff"))
|
|
.build())
|
|
}
|
|
|
|
async fn sync_vpn_clients_handler(
|
|
session: Session,
|
|
db: Database,
|
|
) -> cot::Result<cot::response::Response> {
|
|
let user = match auth::require_admin_or_redirect(&session, &db).await {
|
|
Ok(user) => user,
|
|
Err(_) => {
|
|
return Ok(json_error(
|
|
cot::http::StatusCode::FORBIDDEN,
|
|
"admin role required",
|
|
));
|
|
}
|
|
};
|
|
tracing::info!(
|
|
admin_user_id = user.id,
|
|
"manual client Secret sync requested"
|
|
);
|
|
|
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
|
let sync = match vpn::sync_from_database(&db, &config).await {
|
|
Ok(sync) => sync,
|
|
Err(e) => {
|
|
return Ok(json_error_typed(
|
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
"secret_sync_failed",
|
|
"Secret sync failed",
|
|
"Could not apply client config to VPN servers.",
|
|
&e,
|
|
));
|
|
}
|
|
};
|
|
|
|
Json(sync).into_response()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub struct ApiApp;
|
|
|
|
impl App for ApiApp {
|
|
fn name(&self) -> &'static str {
|
|
"api"
|
|
}
|
|
|
|
fn router(&self) -> Router {
|
|
Router::with_urls([
|
|
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-clients",
|
|
api_get(vpn_clients_handler).post(create_vpn_client_handler),
|
|
"api_vpn_clients",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-status",
|
|
api_get(vpn_status_handler),
|
|
"api_vpn_status",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/telegram-link/status",
|
|
api_get(telegram_link_status_handler),
|
|
"api_telegram_link_status",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/telegram-link/webapp",
|
|
api_post(telegram_link_webapp_handler),
|
|
"api_telegram_link_webapp",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/telegram-link/start",
|
|
api_post(telegram_link_start_handler),
|
|
"api_telegram_link_start",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/telegram-link/decline",
|
|
api_post(telegram_link_decline_handler),
|
|
"api_telegram_link_decline",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/telegram-link/unlink",
|
|
api_post(telegram_link_unlink_handler),
|
|
"api_telegram_link_unlink",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-clients/sync",
|
|
api_post(sync_vpn_clients_handler),
|
|
"api_vpn_clients_sync",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-clients/{id}/enabled",
|
|
api_post(set_vpn_client_enabled_handler),
|
|
"api_vpn_client_enabled",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-clients/{id}",
|
|
api_delete(delete_vpn_client_handler),
|
|
"api_vpn_client_delete",
|
|
),
|
|
Route::with_api_handler_and_name(
|
|
"/vpn-clients/{id}/config",
|
|
api_get(vpn_client_config_handler),
|
|
"api_vpn_client_config",
|
|
),
|
|
])
|
|
}
|
|
}
|