1use 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});