Skip to main content

shared/
lib.rs

1//! Shared library for the Calagopus Panel.
2//!
3//! This library contains code that is shared between the backend and extensions.
4//! It includes models, utilities, and other common functionality to avoid repetition
5//! and ensure consistency across the project. If something for a job exists in here,
6//! it's generally preferred to be used instead of re-implementing it elsewhere.
7
8use anyhow::Context;
9use colored::Colorize;
10use include_dir::Dir;
11use serde::{Deserialize, Serialize};
12use std::{
13    borrow::Cow,
14    sync::{Arc, LazyLock, OnceLock},
15    time::Instant,
16};
17use tokio::sync::RwLock;
18use tower::util::ServiceExt;
19use utoipa::ToSchema;
20
21pub mod cache;
22pub mod cap;
23pub mod captcha;
24pub mod censor;
25pub mod crypt;
26pub mod database;
27pub mod deserialize;
28pub mod env;
29pub mod events;
30pub mod extensions;
31pub mod extract;
32pub mod git;
33#[cfg(unix)]
34pub mod heavy;
35pub mod ignore_list;
36pub mod jwt;
37pub mod mail;
38pub mod models;
39pub mod net;
40pub mod ntp;
41pub mod payload;
42pub mod permissions;
43pub mod prelude;
44pub mod response;
45pub mod retry;
46pub mod settings;
47pub mod storage;
48pub mod telemetry;
49pub mod tunnel;
50pub mod updates;
51pub mod utils;
52
53pub use payload::Payload;
54pub use schema_extension_core::Extendible;
55
56pub const VERSION: &str = env!("CARGO_PKG_VERSION");
57pub const GIT_COMMIT: &str = env!("CARGO_GIT_COMMIT");
58pub const GIT_BRANCH: &str = env!("CARGO_GIT_BRANCH");
59pub const TARGET: &str = env!("CARGO_TARGET");
60
61pub fn full_version() -> String {
62    if GIT_BRANCH == "unknown" {
63        VERSION.to_string()
64    } else {
65        format!("{VERSION}:{GIT_COMMIT}@{GIT_BRANCH}")
66    }
67}
68
69pub const BUFFER_SIZE: usize = 32 * 1024;
70
71pub type GetIp = axum::extract::Extension<std::net::IpAddr>;
72
73#[derive(Clone, Default)]
74pub struct RequestHost(pub Option<compact_str::CompactString>);
75
76impl RequestHost {
77    #[inline]
78    pub fn as_deref(&self) -> Option<&str> {
79        self.0.as_deref()
80    }
81}
82
83pub type GetRequestHost = axum::extract::Extension<RequestHost>;
84
85#[derive(ToSchema, Serialize)]
86pub struct ApiError {
87    pub errors: Vec<String>,
88}
89
90impl ApiError {
91    #[inline]
92    pub fn new_value(errors: &[&str]) -> serde_json::Value {
93        serde_json::json!({
94            "errors": errors,
95        })
96    }
97
98    #[inline]
99    pub fn new_strings_value(errors: Vec<String>) -> serde_json::Value {
100        serde_json::json!({
101            "errors": errors,
102        })
103    }
104
105    #[inline]
106    pub fn new_wings_value(error: wings_api::ApiError) -> serde_json::Value {
107        serde_json::json!({
108            "errors": [error.error],
109        })
110    }
111
112    #[inline]
113    pub fn new_database_agent_value(error: db_agent_api::ApiError) -> serde_json::Value {
114        serde_json::json!({
115            "errors": [error.error],
116        })
117    }
118}
119
120#[derive(Debug, ToSchema, Deserialize, Serialize, Clone, Copy)]
121#[serde(rename_all = "snake_case")]
122pub enum AppContainerType {
123    Official,
124    #[serde(rename = "official_aio")]
125    OfficialAIO,
126    OfficialHeavy,
127    #[serde(rename = "official_heavy_aio")]
128    OfficialHeavyAIO,
129    Unknown,
130    None,
131}
132
133impl AppContainerType {
134    pub fn detect() -> Self {
135        match std::env::var("OCI_CONTAINER").as_deref() {
136            Ok("official") => AppContainerType::Official,
137            Ok("official-aio") => AppContainerType::OfficialAIO,
138            Ok("official-heavy") => AppContainerType::OfficialHeavy,
139            Ok("official-heavy-aio") => AppContainerType::OfficialHeavyAIO,
140            Ok(_) => AppContainerType::Unknown,
141            Err(_) => AppContainerType::None,
142        }
143    }
144
145    #[inline]
146    pub fn is_all_in_one(&self) -> bool {
147        matches!(
148            self,
149            AppContainerType::OfficialAIO | AppContainerType::OfficialHeavyAIO
150        )
151    }
152
153    #[inline]
154    pub fn is_heavy(&self) -> bool {
155        matches!(
156            self,
157            AppContainerType::OfficialHeavy | AppContainerType::OfficialHeavyAIO
158        )
159    }
160}
161
162pub struct AppState {
163    pub start_time: Instant,
164    pub container_type: AppContainerType,
165    pub version: String,
166
167    pub client: reqwest::Client,
168    pub app_router: RwLock<Option<axum::Router>>,
169
170    pub extensions: Arc<extensions::manager::ExtensionManager>,
171    pub updates: Arc<updates::UpdateManager>,
172    pub background_tasks: Arc<extensions::background_tasks::BackgroundTaskManager>,
173    pub shutdown_handlers: Arc<extensions::shutdown_handlers::ShutdownHandlerManager>,
174    pub settings: Arc<settings::Settings>,
175    pub jwt: Arc<jwt::Jwt>,
176    pub ntp: Arc<ntp::Ntp>,
177    pub storage: Arc<storage::Storage>,
178    pub captcha: Arc<captcha::Captcha>,
179    pub mail: Arc<mail::Mail>,
180    pub database: Arc<database::Database>,
181    pub cache: Arc<cache::Cache>,
182    pub env: Arc<env::Env>,
183}
184
185impl AppState {
186    pub fn ensure_extension_management(&self) -> Result<(), response::ApiResponse> {
187        if !self.container_type.is_heavy() {
188            return Err(response::ApiResponse::error(
189                "extension management is only available in the official heavy container",
190            )
191            .with_status(axum::http::StatusCode::NOT_IMPLEMENTED));
192        }
193
194        if self.env.app_disable_extension_management {
195            return Err(response::ApiResponse::error(
196                "extension management has been disabled by an administrator",
197            )
198            .with_status(axum::http::StatusCode::FORBIDDEN));
199        }
200
201        Ok(())
202    }
203
204    pub async fn new_cli(env: Option<Arc<env::Env>>) -> Result<State, anyhow::Error> {
205        let env = match env {
206            Some(env) => env,
207            None => {
208                eprintln!(
209                    "{}",
210                    "please setup the new panel environment before using this command.".red()
211                );
212                std::process::exit(1);
213            }
214        };
215
216        let jwt = Arc::new(jwt::Jwt::new(&env));
217        let ntp = ntp::Ntp::new();
218        let cache = cache::Cache::new(&env).await;
219        let database = Arc::new(database::Database::new(&env, cache.clone()).await);
220
221        let background_tasks =
222            Arc::new(extensions::background_tasks::BackgroundTaskManager::default());
223        let shutdown_handlers =
224            Arc::new(extensions::shutdown_handlers::ShutdownHandlerManager::default());
225        let settings = Arc::new(
226            settings::Settings::new(database.clone())
227                .await
228                .context("failed to load settings")?,
229        );
230        let storage = Arc::new(storage::Storage::new(settings.clone()));
231        let captcha = Arc::new(captcha::Captcha::new(settings.clone()));
232        let mail = Arc::new(mail::Mail::new(settings.clone()));
233
234        let state = Arc::new(AppState {
235            start_time: Instant::now(),
236            container_type: AppContainerType::detect(),
237            version: full_version(),
238
239            client: reqwest::ClientBuilder::new()
240                .user_agent(format!("github.com/calagopus/panel {}", VERSION))
241                .connect_timeout(std::time::Duration::from_secs(10))
242                .read_timeout(std::time::Duration::from_secs(60))
243                .build()
244                .unwrap(),
245            app_router: RwLock::new(None),
246
247            extensions: Arc::new(extensions::manager::ExtensionManager::new(vec![])),
248            updates: Arc::new(updates::UpdateManager::default()),
249            background_tasks: background_tasks.clone(),
250            shutdown_handlers: shutdown_handlers.clone(),
251            settings: settings.clone(),
252            jwt,
253            ntp,
254            storage,
255            captcha,
256            mail,
257            database: database.clone(),
258            cache: cache.clone(),
259            env: env.clone(),
260        });
261
262        Ok(state)
263    }
264
265    pub async fn send_router_oneshot(
266        &self,
267        req: axum::http::Request<axum::body::Body>,
268    ) -> Result<axum::http::Response<axum::body::Body>, anyhow::Error> {
269        let routes_service = self.app_router.read().await;
270        let routes_service = routes_service
271            .as_ref()
272            .ok_or_else(|| anyhow::anyhow!("router not initialized"))?;
273        let routes_service = routes_service.clone();
274
275        let svc = routes_service.oneshot(req);
276        match svc.await {
277            Ok(res) => Ok(res),
278            Err(err) => Err(anyhow::anyhow!(
279                "failed to process request in oneshot router: {:#?}",
280                err
281            )),
282        }
283    }
284
285    pub async fn send_authenticated_router_oneshot(
286        &self,
287        mut req: axum::http::Request<axum::body::Body>,
288        user: models::user::User,
289        auth_method: models::user::AuthMethod,
290    ) -> Result<axum::http::Response<axum::body::Body>, anyhow::Error> {
291        let routes_service = self.app_router.read().await;
292        let routes_service = routes_service
293            .as_ref()
294            .ok_or_else(|| anyhow::anyhow!("router not initialized"))?;
295        let routes_service = routes_service.clone();
296
297        req.extensions_mut().insert((user, auth_method));
298
299        let svc = routes_service.oneshot(req);
300        match svc.await {
301            Ok(res) => Ok(res),
302            Err(err) => Err(anyhow::anyhow!(
303                "failed to process request in oneshot router: {:#?}",
304                err
305            )),
306        }
307    }
308}
309
310pub type State = Arc<AppState>;
311pub type GetState = axum::extract::State<State>;
312
313#[inline(always)]
314#[cold]
315fn cold_path() {}
316
317#[inline(always)]
318pub fn likely(b: bool) -> bool {
319    if b {
320        true
321    } else {
322        cold_path();
323        false
324    }
325}
326
327#[inline(always)]
328pub fn unlikely(b: bool) -> bool {
329    if b {
330        cold_path();
331        true
332    } else {
333        false
334    }
335}
336
337pub struct EmbeddedFrontend {
338    pub assets: &'static Dir<'static>,
339    pub index_html: &'static str,
340}
341
342static EMBEDDED_FRONTEND: OnceLock<EmbeddedFrontend> = OnceLock::new();
343
344pub fn register_frontend(frontend: EmbeddedFrontend) {
345    EMBEDDED_FRONTEND.set(frontend).ok();
346}
347
348fn frontend_assets() -> &'static Dir<'static> {
349    static EMPTY: Dir<'static> = Dir::new("", &[]);
350
351    EMBEDDED_FRONTEND
352        .get()
353        .map_or(&EMPTY, |frontend| frontend.assets)
354}
355
356pub fn frontend_index_html() -> Option<&'static str> {
357    EMBEDDED_FRONTEND.get().map(|frontend| frontend.index_html)
358}
359
360pub struct FrontendAsset {
361    pub path: &'static str,
362    pub contents: &'static [u8],
363    pub gzipped: bool,
364}
365
366impl FrontendAsset {
367    pub fn decompressed(&self) -> Result<Cow<'static, [u8]>, std::io::Error> {
368        if !self.gzipped {
369            return Ok(Cow::Borrowed(self.contents));
370        }
371
372        let mut decompressed = Vec::with_capacity(self.contents.len() * 4);
373        std::io::Read::read_to_end(
374            &mut flate2::read::GzDecoder::new(self.contents),
375            &mut decompressed,
376        )?;
377
378        Ok(Cow::Owned(decompressed))
379    }
380}
381
382pub fn frontend_asset(path: &str) -> Option<FrontendAsset> {
383    if let Some(file) = frontend_assets().get_file(path) {
384        return Some(FrontendAsset {
385            path: file.path().to_str()?,
386            contents: file.contents(),
387            gzipped: false,
388        });
389    }
390
391    let file = frontend_assets().get_file(format!("{path}.gz"))?;
392
393    Some(FrontendAsset {
394        path: file.path().to_str()?.strip_suffix(".gz")?,
395        contents: file.contents(),
396        gzipped: true,
397    })
398}
399
400pub static FRONTEND_LANGUAGES: LazyLock<Vec<compact_str::CompactString>> = LazyLock::new(|| {
401    let mut languages = Vec::new();
402
403    let Some(translations) = frontend_assets().get_dir("translations") else {
404        return languages;
405    };
406
407    for translation in translations.files() {
408        let Some(file_name) = translation.path().file_name() else {
409            continue;
410        };
411        let file_name = file_name.to_string_lossy();
412        let file_name = file_name.strip_suffix(".gz").unwrap_or(&file_name);
413        let Some(lang) = file_name.strip_suffix(".json") else {
414            continue;
415        };
416
417        languages.push(lang.into());
418    }
419
420    languages
421});