1use crate::{
2 censor::{CENSORED_PLACEHOLDER, Censor},
3 models::{InsertQueryBuilder, UpdateQueryBuilder},
4 prelude::*,
5};
6use compact_str::ToCompactString;
7use futures_util::StreamExt;
8use garde::Validate;
9use serde::{Deserialize, Serialize};
10use sha2::Digest;
11use sqlx::{Row, postgres::PgRow};
12use std::{
13 collections::{BTreeMap, HashMap, HashSet},
14 io::Read,
15 path::PathBuf,
16 sync::{Arc, LazyLock},
17};
18use utoipa::ToSchema;
19
20fn default_true() -> bool {
21 true
22}
23
24const BANNER_MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
25const BANNER_MAX_WIDTH: u32 = 960;
26const BANNER_MAX_HEIGHT: u32 = 480;
27
28fn is_banner_file_name(name: &str) -> bool {
29 let Some((stem, extension)) = name.rsplit_once('.') else {
30 return false;
31 };
32
33 stem.eq_ignore_ascii_case("banner")
34 && ["png", "jpg", "jpeg", "webp", "gif"]
35 .iter()
36 .any(|e| extension.eq_ignore_ascii_case(e))
37}
38
39fn banner_storage_path(egg_repository_uuid: uuid::Uuid, source: &[u8]) -> String {
40 let mut hasher = sha2::Sha256::new();
41 hasher.update(b"v1");
42 hasher.update(source);
43
44 format!(
45 "publicdata/egg-repositories/{}/banners/{}.webp",
46 egg_repository_uuid,
47 hex::encode(&hasher.finalize()[..16])
48 )
49}
50
51async fn transcode_banner(data: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
52 let image = super::user::avatar::decode(data, super::user::avatar::upload_limits()).await?;
53
54 tokio::task::spawn_blocking(move || -> Result<Vec<u8>, anyhow::Error> {
55 let image = if image.width() > BANNER_MAX_WIDTH || image.height() > BANNER_MAX_HEIGHT {
56 image.resize(
57 BANNER_MAX_WIDTH,
58 BANNER_MAX_HEIGHT,
59 image::imageops::FilterType::Triangle,
60 )
61 } else {
62 image
63 };
64 let image = image::DynamicImage::ImageRgba8(image.into_rgba8());
65
66 let mut data = Vec::new();
67 image::codecs::webp::WebPEncoder::new_lossless(&mut data).encode(
68 image.as_bytes(),
69 image.width(),
70 image.height(),
71 image::ExtendedColorType::Rgba8,
72 )?;
73
74 Ok(data)
75 })
76 .await?
77}
78
79fn validate_git_repository(
80 git_repository: &compact_str::CompactString,
81 _context: &(),
82) -> Result<(), garde::Error> {
83 let url = match gix::url::parse(git_repository.as_str()) {
84 Ok(url) => url,
85 Err(err) => return Err(garde::Error::new(format!("Invalid git repository: {err}"))),
86 };
87
88 match url.scheme {
89 gix::url::Scheme::Http | gix::url::Scheme::Https | gix::url::Scheme::Ssh => {}
90 _ => {
91 return Err(garde::Error::new(
92 "Invalid git repository: only http, https, ssh and scp-style (user@host:path) urls are supported",
93 ));
94 }
95 }
96
97 if url.host().is_none() {
98 return Err(garde::Error::new("Invalid git repository: missing host"));
99 }
100
101 Ok(())
102}
103
104fn same_origin(a: &str, b: &str) -> bool {
106 match (gix::url::parse(a), gix::url::parse(b)) {
107 (Ok(a), Ok(b)) => {
108 a.scheme == b.scheme
109 && a.port_or_default() == b.port_or_default()
110 && a.host()
111 .zip(b.host())
112 .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))
113 }
114 _ => false,
115 }
116}
117
118#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
119#[serde(tag = "type", rename_all = "snake_case")]
120pub enum EggRepositoryCredentials {
121 None,
122 Password {
123 #[garde(length(chars, min = 1, max = 255))]
124 #[schema(min_length = 1, max_length = 255)]
125 username: compact_str::CompactString,
126 #[garde(length(chars, min = 1, max = 255))]
127 #[schema(min_length = 1, max_length = 255)]
128 password: compact_str::CompactString,
129 },
130 PrivateKey {
131 #[garde(length(chars, min = 1, max = 255))]
132 #[schema(min_length = 1, max_length = 255)]
133 username: compact_str::CompactString,
134 #[garde(
135 length(chars, min = 1, max = 16384),
136 custom(crate::git::validate_private_key)
137 )]
138 #[schema(min_length = 1, max_length = 16384)]
139 private_key: String,
140 #[garde(inner(length(chars, min = 1, max = 255)))]
141 #[schema(min_length = 1, max_length = 255)]
142 passphrase: Option<compact_str::CompactString>,
143 },
144}
145
146impl EggRepositoryCredentials {
147 fn validate_scheme_for_url(&self, git_repository: &str) -> Result<(), anyhow::Error> {
148 let scheme = gix::url::parse(git_repository)
149 .map(|url| url.scheme)
150 .unwrap_or(gix::url::Scheme::Https);
151
152 match self {
153 EggRepositoryCredentials::None => {}
154 EggRepositoryCredentials::Password { .. } => {
155 if scheme == gix::url::Scheme::Http {
156 return Err(crate::response::DisplayError::new(
157 "password credentials cannot be sent over plain http, use an https repository url",
158 )
159 .into());
160 }
161 }
162 EggRepositoryCredentials::PrivateKey { .. } => {
163 if scheme != gix::url::Scheme::Ssh {
164 return Err(crate::response::DisplayError::new(
165 "private key credentials can only be used with ssh repositories",
166 )
167 .into());
168 }
169 }
170 }
171
172 Ok(())
173 }
174
175 fn validate_key_material(&self) -> Result<(), anyhow::Error> {
176 if let EggRepositoryCredentials::PrivateKey {
177 private_key,
178 passphrase,
179 ..
180 } = self
181 {
182 crate::git::parse_private_key(private_key, passphrase.as_deref()).map_err(|err| {
183 crate::response::DisplayError::new(format!("private key is unusable: {err}"))
184 })?;
185 }
186
187 Ok(())
188 }
189
190 pub async fn encrypt(
191 &mut self,
192 database: &crate::database::Database,
193 ) -> Result<(), anyhow::Error> {
194 match self {
195 EggRepositoryCredentials::None => {}
196 EggRepositoryCredentials::Password { password, .. } => {
197 *password = database.encrypt_base64(password.clone()).await?;
198 }
199 EggRepositoryCredentials::PrivateKey {
200 private_key,
201 passphrase,
202 ..
203 } => {
204 *private_key = database.encrypt_base64(private_key.clone()).await?.into();
205
206 if let Some(passphrase) = passphrase {
207 *passphrase = database.encrypt_base64(passphrase.clone()).await?;
208 }
209 }
210 }
211
212 Ok(())
213 }
214
215 pub async fn decrypt(
216 &mut self,
217 database: &crate::database::Database,
218 ) -> Result<(), anyhow::Error> {
219 match self {
220 EggRepositoryCredentials::None => {}
221 EggRepositoryCredentials::Password { password, .. } => {
222 if let Some(decrypted) = database.decrypt_base64_optional(&password).await? {
223 *password = decrypted;
224 }
225 }
226 EggRepositoryCredentials::PrivateKey {
227 private_key,
228 passphrase,
229 ..
230 } => {
231 if let Some(decrypted) = database.decrypt_base64_optional(&private_key).await? {
232 *private_key = decrypted.into();
233 }
234
235 if let Some(passphrase) = passphrase
236 && let Some(decrypted) = database.decrypt_base64_optional(&passphrase).await?
237 {
238 *passphrase = decrypted;
239 }
240 }
241 }
242
243 Ok(())
244 }
245}
246
247impl Censor for EggRepositoryCredentials {
248 fn censor(&mut self) {
249 match self {
250 EggRepositoryCredentials::None => {}
251 EggRepositoryCredentials::Password { password, .. } => {
252 *password = CENSORED_PLACEHOLDER.into();
253 }
254 EggRepositoryCredentials::PrivateKey {
255 private_key,
256 passphrase,
257 ..
258 } => {
259 *private_key = CENSORED_PLACEHOLDER.into();
260
261 if let Some(passphrase) = passphrase {
262 *passphrase = CENSORED_PLACEHOLDER.into();
263 }
264 }
265 }
266 }
267}
268
269impl From<EggRepositoryCredentials> for crate::git::GitCredentials {
270 fn from(value: EggRepositoryCredentials) -> Self {
271 match value {
272 EggRepositoryCredentials::None => Self::None,
273 EggRepositoryCredentials::Password { username, password } => {
274 Self::Password { username, password }
275 }
276 EggRepositoryCredentials::PrivateKey {
277 username,
278 private_key,
279 passphrase,
280 } => Self::PrivateKey {
281 username,
282 private_key,
283 passphrase,
284 },
285 }
286 }
287}
288
289#[derive(Serialize, Deserialize, Clone)]
290pub struct EggRepository {
291 pub uuid: uuid::Uuid,
292
293 pub name: compact_str::CompactString,
294 pub description: Option<compact_str::CompactString>,
295 pub git_repository: compact_str::CompactString,
296 pub credentials: EggRepositoryCredentials,
297 pub default_nest: Option<Fetchable<super::nest::Nest>>,
298 pub auto_sync: bool,
299
300 pub last_synced: Option<chrono::NaiveDateTime>,
301 pub created: chrono::NaiveDateTime,
302
303 extension_data: super::ModelExtensionData,
304}
305
306impl BaseModel for EggRepository {
307 const NAME: &'static str = "egg_repository";
308
309 fn get_extension_list() -> &'static super::ModelExtensionList {
310 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
311 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
312
313 &EXTENSIONS
314 }
315
316 fn get_extension_data(&self) -> &super::ModelExtensionData {
317 &self.extension_data
318 }
319
320 #[inline]
321 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
322 let prefix = prefix.unwrap_or_default();
323
324 BTreeMap::from([
325 (
326 "egg_repositories.uuid",
327 compact_str::format_compact!("{prefix}uuid"),
328 ),
329 (
330 "egg_repositories.name",
331 compact_str::format_compact!("{prefix}name"),
332 ),
333 (
334 "egg_repositories.description",
335 compact_str::format_compact!("{prefix}description"),
336 ),
337 (
338 "egg_repositories.git_repository",
339 compact_str::format_compact!("{prefix}git_repository"),
340 ),
341 (
342 "egg_repositories.credentials",
343 compact_str::format_compact!("{prefix}credentials"),
344 ),
345 (
346 "egg_repositories.default_nest_uuid",
347 compact_str::format_compact!("{prefix}default_nest_uuid"),
348 ),
349 (
350 "egg_repositories.auto_sync",
351 compact_str::format_compact!("{prefix}auto_sync"),
352 ),
353 (
354 "egg_repositories.last_synced",
355 compact_str::format_compact!("{prefix}last_synced"),
356 ),
357 (
358 "egg_repositories.created",
359 compact_str::format_compact!("{prefix}created"),
360 ),
361 ])
362 }
363
364 #[inline]
365 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
366 let prefix = prefix.unwrap_or_default();
367
368 Ok(Self {
369 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
370 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
371 description: row
372 .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
373 git_repository: row
374 .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
375 credentials: serde_json::from_value(
376 row.try_get(compact_str::format_compact!("{prefix}credentials").as_str())?,
377 )?,
378 default_nest: row
379 .try_get::<Option<uuid::Uuid>, _>(
380 compact_str::format_compact!("{prefix}default_nest_uuid").as_str(),
381 )?
382 .map(super::nest::Nest::get_fetchable),
383 auto_sync: row.try_get(compact_str::format_compact!("{prefix}auto_sync").as_str())?,
384 last_synced: row
385 .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
386 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
387 extension_data: Self::map_extensions(prefix, row)?,
388 })
389 }
390
391 fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
392 vec![compact_str::format_compact!(
393 "{}::{}",
394 Self::NAME,
395 self.uuid
396 )]
397 }
398}
399
400impl EggRepository {
401 pub async fn all_with_pagination(
402 database: &crate::database::Database,
403 page: i64,
404 per_page: i64,
405 search: Option<&str>,
406 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
407 super::PaginatedQueryBuilder::new(
408 Self::columns_sql(None),
409 format!(
410 r#"
411 FROM egg_repositories
412 WHERE {search}
413 "#,
414 search =
415 super::search_sql(1, &["egg_repositories.name"], &["egg_repositories.uuid"]),
416 ),
417 "egg_repositories.created, egg_repositories.uuid",
418 )
419 .bind(search)
420 .fetch(database, page, per_page)
421 .await
422 }
423
424 pub async fn all_auto_sync(
425 database: &crate::database::Database,
426 ) -> Result<Vec<Self>, crate::database::DatabaseError> {
427 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
428 r#"
429 SELECT {}
430 FROM egg_repositories
431 WHERE egg_repositories.auto_sync
432 ORDER BY egg_repositories.created, egg_repositories.uuid
433 "#,
434 Self::columns_sql(None)
435 )))
436 .fetch_all(database.read())
437 .await?;
438
439 rows.into_iter()
440 .map(|row| Self::map(None, &row))
441 .try_collect_vec()
442 }
443
444 pub async fn sync(&self, state: &crate::State) -> Result<usize, anyhow::Error> {
445 let git_repository = self.git_repository.clone();
446
447 let mut credentials = self.credentials.clone();
448 credentials.decrypt(&state.database).await?;
449
450 let url = gix::url::parse(git_repository.as_str())?;
451 crate::git::resolve_addresses(&state.env, &url).await?;
452
453 let env = Arc::clone(&state.env);
454
455 struct FoundEgg {
456 path: PathBuf,
457 readme: Option<String>,
458 banner: Option<String>,
459 exported_egg: super::nest_egg::ExportedNestEgg,
460 updated: chrono::DateTime<chrono::Utc>,
461 }
462 type NewBanners = HashMap<String, Vec<u8>>;
463
464 let egg_repository_uuid = self.uuid;
465 let previous_banners: HashSet<String> =
466 super::egg_repository_egg::EggRepositoryEgg::banners_by_egg_repository_uuid(
467 &state.database,
468 self.uuid,
469 )
470 .await?
471 .into_iter()
472 .collect();
473 let known_banners = previous_banners.clone();
474
475 let (mut exported_eggs, new_banners) = tokio::task::spawn_blocking(
476 move || -> Result<(Vec<FoundEgg>, NewBanners), anyhow::Error> {
477 let mut exported_eggs = Vec::new();
478 let mut banner_files: HashMap<PathBuf, Option<String>> = HashMap::new();
479 let mut new_banners: HashMap<String, Vec<u8>> = HashMap::new();
480 let temp_dir = tempfile::tempdir()?;
481 let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
482
483 let mut prepare_fetch = gix::clone::PrepareFetch::new(
484 url.clone(),
485 temp_dir.path(),
486 gix::create::Kind::WithWorktree,
487 Default::default(),
488 gix::open::Options::default()
489 .config_overrides(["credential.helper=", "http.followRedirects=false"]),
490 )?
491 .configure_connection(
492 crate::git::GitCredentials::from(credentials)
493 .into_connection_configurator(env, url),
494 );
495
496 let (mut prepare_checkout, _) = prepare_fetch
497 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
498 let _ = prepare_checkout
499 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
500
501 tracing::info!(
502 "cloned egg repository {} to temporary directory",
503 git_repository
504 );
505
506 let mut walker = filesystem.walk_dir(".")?;
507 while let Some(Ok((is_dir, entry))) = walker.next_entry() {
508 if is_dir
509 || !matches!(
510 entry.extension().and_then(|s| s.to_str()),
511 Some("json") | Some("yml") | Some("yaml")
512 )
513 {
514 continue;
515 }
516
517 let metadata = match filesystem.metadata(&entry) {
518 Ok(metadata) => metadata,
519 Err(_) => continue,
520 };
521
522 if !metadata.is_file()
523 || metadata.len() > super::nest_egg::NestEgg::MAX_EXPORTED_SIZE as u64
524 {
525 continue;
526 }
527
528 let file_content = match filesystem.read_to_string(&entry) {
529 Ok(content) => content,
530 Err(_) => continue,
531 };
532 let exported_egg: super::nest_egg::ExportedNestEgg =
533 if entry.extension().and_then(|s| s.to_str()) == Some("json") {
534 match serde_json::from_str(file_content.trim()) {
535 Ok(egg) => egg,
536 Err(_) => continue,
537 }
538 } else {
539 match serde_saphyr::from_str_with_options(
540 file_content.trim(),
541 crate::utils::yaml_deserialize_options(),
542 ) {
543 Ok(egg) => egg,
544 Err(_) => continue,
545 }
546 };
547
548 let mut readme = None;
549 let mut banner = None;
550 let mut current_path = entry.parent();
551 while let Some(path) = current_path
552 && (readme.is_none() || banner.is_none())
553 {
554 let mut dir = filesystem.read_dir(path)?;
555
556 while let Some(Ok((is_dir, entry))) = dir.next_entry() {
557 if is_dir {
558 continue;
559 }
560
561 let path = path.join(&entry);
562
563 if readme.is_none()
564 && entry.to_lowercase().contains("readme")
565 && filesystem
566 .metadata(&path)
567 .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
568 && let Ok(content) = filesystem.read_to_string(&path)
569 {
570 readme = Some(content);
571 } else if banner.is_none() && is_banner_file_name(&entry) {
572 banner = banner_files
573 .entry(path.clone())
574 .or_insert_with(|| {
575 if !filesystem.metadata(&path).is_ok_and(|m| {
576 m.is_file() && m.len() <= BANNER_MAX_SOURCE_BYTES
577 }) {
578 return None;
579 }
580
581 let mut data = Vec::new();
582 filesystem
583 .open(&path)
584 .ok()?
585 .take(BANNER_MAX_SOURCE_BYTES)
586 .read_to_end(&mut data)
587 .ok()?;
588
589 let storage_path =
590 banner_storage_path(egg_repository_uuid, &data);
591 if !known_banners.contains(&storage_path) {
592 new_banners.insert(storage_path.clone(), data);
593 }
594
595 Some(storage_path)
596 })
597 .clone();
598 }
599 }
600
601 current_path = path.parent();
602 }
603
604 exported_eggs.push(FoundEgg {
605 path: entry,
606 readme,
607 banner,
608 exported_egg,
609 updated: chrono::DateTime::from_timestamp(
610 metadata
611 .modified()
612 .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
613 .duration_since(std::time::UNIX_EPOCH)
614 .unwrap_or_default()
615 .as_secs() as i64,
616 0,
617 )
618 .unwrap_or_else(chrono::Utc::now),
619 });
620 }
621
622 drop(prepare_fetch);
623
624 Ok((exported_eggs, new_banners))
625 },
626 )
627 .await??;
628
629 let mut failed_banners = HashSet::new();
630 for (path, data) in new_banners {
631 let result = match transcode_banner(data).await {
632 Ok(data) => {
633 state
634 .storage
635 .store_with_cache_control(
636 &path,
637 data.as_slice(),
638 "image/webp",
639 Some("public, max-age=31536000, immutable"),
640 )
641 .await
642 }
643 Err(err) => Err(err),
644 };
645
646 if let Err(err) = result {
647 tracing::warn!(
648 "failed to store egg repository banner {} for {}: {:?}",
649 path,
650 self.git_repository,
651 err
652 );
653 failed_banners.insert(path);
654 }
655 }
656
657 for egg in exported_eggs.iter_mut() {
658 if egg
659 .banner
660 .as_ref()
661 .is_some_and(|banner| failed_banners.contains(banner))
662 {
663 egg.banner = None;
664 }
665 }
666
667 super::egg_repository_egg::EggRepositoryEgg::delete_unused(
668 &state.database,
669 self.uuid,
670 &exported_eggs
671 .iter()
672 .map(|egg| egg.path.to_string_lossy().to_compact_string())
673 .collect::<Vec<_>>(),
674 )
675 .await?;
676
677 let mut futures = Vec::new();
678 futures.reserve_exact(exported_eggs.len());
679
680 for egg in exported_eggs.iter() {
681 futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
682 &state.database,
683 self.uuid,
684 egg.path.to_string_lossy(),
685 egg.readme.as_deref(),
686 egg.banner.as_deref(),
687 &egg.exported_egg,
688 egg.updated.naive_utc(),
689 ));
690 }
691
692 let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
693 while let Some(result) = results_stream.next().await {
694 result?;
695 }
696
697 sqlx::query(
698 r#"
699 UPDATE egg_repositories
700 SET last_synced = NOW()
701 WHERE egg_repositories.uuid = $1
702 "#,
703 )
704 .bind(self.uuid)
705 .execute(state.database.write())
706 .await?;
707
708 Self::invalidate_cached(&state.database, self.uuid).await;
709
710 let current_banners: HashSet<String> =
711 super::egg_repository_egg::EggRepositoryEgg::banners_by_egg_repository_uuid(
712 &state.database,
713 self.uuid,
714 )
715 .await?
716 .into_iter()
717 .collect();
718 for banner in previous_banners.difference(¤t_banners) {
719 state.storage.remove(Some(banner)).await?;
720 }
721
722 Ok(exported_eggs.len())
723 }
724}
725
726#[async_trait::async_trait]
727impl IntoAdminApiObject for EggRepository {
728 type AdminApiObject = AdminApiEggRepository;
729 type ExtraArgs<'a> = ();
730
731 async fn into_admin_api_object<'a>(
732 mut self,
733 state: &crate::State,
734 _args: Self::ExtraArgs<'a>,
735 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
736 let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
737
738 self.credentials.censor();
739
740 let default_nest = match self.default_nest {
741 Some(default_nest) => Some(
742 default_nest
743 .fetch_cached(&state.database)
744 .await?
745 .into_admin_api_object(state, ())
746 .await?,
747 ),
748 None => None,
749 };
750
751 let api_object = finish_extendible!(
752 AdminApiEggRepository {
753 uuid: self.uuid,
754 name: self.name,
755 description: self.description,
756 git_repository: self.git_repository,
757 credentials: self.credentials,
758 default_nest,
759 auto_sync: self.auto_sync,
760 last_synced: self.last_synced.map(|dt| dt.and_utc()),
761 created: self.created.and_utc(),
762 },
763 api_object,
764 state
765 )?;
766
767 Ok(api_object)
768 }
769}
770
771#[derive(ToSchema, Deserialize, Validate)]
772pub struct CreateEggRepositoryOptions {
773 #[garde(length(chars, min = 1, max = 255))]
774 #[schema(min_length = 1, max_length = 255)]
775 pub name: compact_str::CompactString,
776 #[garde(length(max = 1024))]
777 #[schema(max_length = 1024)]
778 pub description: Option<compact_str::CompactString>,
779 #[garde(custom(validate_git_repository))]
780 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
781 pub git_repository: compact_str::CompactString,
782 #[garde(dive)]
783 pub credentials: Option<EggRepositoryCredentials>,
784 #[garde(skip)]
785 pub default_nest_uuid: Option<uuid::Uuid>,
786 #[garde(skip)]
787 #[serde(default = "default_true")]
788 pub auto_sync: bool,
789}
790
791#[async_trait::async_trait]
792impl CreatableModel for EggRepository {
793 type CreateOptions<'a> = CreateEggRepositoryOptions;
794 type CreateResult = Self;
795
796 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
797 static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
798 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
799
800 &CREATE_LISTENERS
801 }
802
803 async fn create_with_transaction(
804 state: &crate::State,
805 mut options: Self::CreateOptions<'_>,
806 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
807 ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
808 options.validate()?;
809
810 let mut query_builder = InsertQueryBuilder::new("egg_repositories");
811
812 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
813
814 let credentials = options
815 .credentials
816 .get_or_insert(EggRepositoryCredentials::None);
817 credentials.validate_scheme_for_url(&options.git_repository)?;
818 credentials.validate_key_material()?;
819 credentials.encrypt(&state.database).await?;
820
821 if let Some(default_nest_uuid) = options.default_nest_uuid {
822 super::nest::Nest::by_uuid_optional_cached(&state.database, default_nest_uuid)
823 .await?
824 .ok_or(crate::database::InvalidRelationError("nest"))?;
825 }
826
827 query_builder
828 .set("name", &options.name)
829 .set("description", &options.description)
830 .set("git_repository", &options.git_repository)
831 .set("credentials", serde_json::to_value(&credentials)?)
832 .set("default_nest_uuid", options.default_nest_uuid)
833 .set("auto_sync", options.auto_sync);
834
835 let row = query_builder
836 .returning(&Self::columns_sql(None))
837 .fetch_one(&mut **transaction)
838 .await?;
839 let mut egg_repository = Self::map(None, &row)?;
840
841 Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
842
843 Ok(egg_repository)
844 }
845}
846
847#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
848pub struct UpdateEggRepositoryOptions {
849 #[garde(length(chars, min = 1, max = 255))]
850 #[schema(min_length = 1, max_length = 255)]
851 pub name: Option<compact_str::CompactString>,
852 #[garde(length(max = 1024))]
853 #[schema(max_length = 1024)]
854 #[serde(
855 default,
856 skip_serializing_if = "Option::is_none",
857 with = "::serde_with::rust::double_option"
858 )]
859 pub description: Option<Option<compact_str::CompactString>>,
860 #[garde(inner(custom(validate_git_repository)))]
861 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
862 pub git_repository: Option<compact_str::CompactString>,
863 #[garde(dive)]
864 pub credentials: Option<EggRepositoryCredentials>,
865 #[garde(skip)]
866 #[serde(
867 default,
868 skip_serializing_if = "Option::is_none",
869 with = "::serde_with::rust::double_option"
870 )]
871 pub default_nest_uuid: Option<Option<uuid::Uuid>>,
872 #[garde(skip)]
873 pub auto_sync: Option<bool>,
874}
875
876#[async_trait::async_trait]
877impl UpdatableModel for EggRepository {
878 type UpdateOptions = UpdateEggRepositoryOptions;
879
880 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
881 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
882 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
883
884 &UPDATE_LISTENERS
885 }
886
887 async fn update_with_transaction(
888 &mut self,
889 state: &crate::State,
890 mut options: Self::UpdateOptions,
891 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
892 ) -> Result<(), crate::database::DatabaseError> {
893 options.validate()?;
894
895 if options.credentials.is_none()
896 && options
897 .git_repository
898 .as_deref()
899 .is_some_and(|git_repository| !same_origin(git_repository, &self.git_repository))
900 {
901 options.credentials = Some(EggRepositoryCredentials::None);
902 }
903
904 let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
905
906 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
907 .await?;
908
909 let git_repository = options
910 .git_repository
911 .as_deref()
912 .unwrap_or(&self.git_repository);
913
914 match &mut options.credentials {
915 Some(credentials) => {
916 credentials.validate_scheme_for_url(git_repository)?;
917 credentials.validate_key_material()?;
918 credentials.encrypt(&state.database).await?;
919 }
920 None => self.credentials.validate_scheme_for_url(git_repository)?,
921 }
922
923 if let Some(Some(default_nest_uuid)) = options.default_nest_uuid {
924 super::nest::Nest::by_uuid_optional_cached(&state.database, default_nest_uuid)
925 .await?
926 .ok_or(crate::database::InvalidRelationError("nest"))?;
927 }
928
929 query_builder
930 .set("name", options.name.as_ref())
931 .set(
932 "description",
933 options.description.as_ref().map(|d| d.as_ref()),
934 )
935 .set("git_repository", options.git_repository.as_ref())
936 .set(
937 "credentials",
938 options
939 .credentials
940 .as_ref()
941 .map(serde_json::to_value)
942 .transpose()?,
943 )
944 .set(
945 "default_nest_uuid",
946 options.default_nest_uuid.as_ref().map(|o| o.as_ref()),
947 )
948 .set("auto_sync", options.auto_sync)
949 .where_eq("uuid", self.uuid);
950
951 query_builder.execute(&mut **transaction).await?;
952
953 if let Some(name) = options.name {
954 self.name = name;
955 }
956 if let Some(description) = options.description {
957 self.description = description;
958 }
959 if let Some(git_repository) = options.git_repository {
960 self.git_repository = git_repository;
961 }
962 if let Some(credentials) = options.credentials {
963 self.credentials = credentials;
964 }
965 if let Some(default_nest_uuid) = options.default_nest_uuid {
966 self.default_nest = default_nest_uuid.map(super::nest::Nest::get_fetchable);
967 }
968 if let Some(auto_sync) = options.auto_sync {
969 self.auto_sync = auto_sync;
970 }
971
972 self.run_after_update_handlers(state, transaction).await?;
973
974 Ok(())
975 }
976}
977
978#[async_trait::async_trait]
979impl ByUuid for EggRepository {
980 async fn by_uuid(
981 database: &crate::database::Database,
982 uuid: uuid::Uuid,
983 ) -> Result<Self, crate::database::DatabaseError> {
984 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
985 r#"
986 SELECT {}
987 FROM egg_repositories
988 WHERE egg_repositories.uuid = $1
989 "#,
990 Self::columns_sql(None)
991 )))
992 .bind(uuid)
993 .fetch_one(database.read())
994 .await?;
995
996 Self::map(None, &row)
997 }
998
999 async fn by_uuid_with_transaction(
1000 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1001 uuid: uuid::Uuid,
1002 ) -> Result<Self, crate::database::DatabaseError> {
1003 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1004 r#"
1005 SELECT {}
1006 FROM egg_repositories
1007 WHERE egg_repositories.uuid = $1
1008 "#,
1009 Self::columns_sql(None)
1010 )))
1011 .bind(uuid)
1012 .fetch_one(&mut **transaction)
1013 .await?;
1014
1015 Self::map(None, &row)
1016 }
1017}
1018
1019#[async_trait::async_trait]
1020impl DeletableModel for EggRepository {
1021 type DeleteOptions = ();
1022
1023 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
1024 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
1025 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1026
1027 &DELETE_LISTENERS
1028 }
1029
1030 async fn delete_with_transaction(
1031 &self,
1032 state: &crate::State,
1033 options: Self::DeleteOptions,
1034 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1035 ) -> Result<(), anyhow::Error> {
1036 self.run_delete_handlers(&options, state, transaction)
1037 .await?;
1038
1039 sqlx::query(
1040 r#"
1041 DELETE FROM egg_repositories
1042 WHERE egg_repositories.uuid = $1
1043 "#,
1044 )
1045 .bind(self.uuid)
1046 .execute(&mut **transaction)
1047 .await?;
1048
1049 self.run_after_delete_handlers(&options, state, transaction)
1050 .await?;
1051
1052 Ok(())
1053 }
1054 async fn delete(
1055 &self,
1056 state: &crate::State,
1057 options: Self::DeleteOptions,
1058 ) -> Result<(), anyhow::Error> {
1059 let banners = super::egg_repository_egg::EggRepositoryEgg::banners_by_egg_repository_uuid(
1060 &state.database,
1061 self.uuid,
1062 )
1063 .await?;
1064
1065 let mut transaction = state.database.write().begin().await?;
1066
1067 if let Err(err) = self
1068 .delete_with_transaction(state, options, &mut transaction)
1069 .await
1070 {
1071 transaction.rollback().await?;
1072 return Err(err);
1073 }
1074
1075 transaction.commit().await?;
1076
1077 Self::invalidate_cached(&state.database, self.uuid).await;
1078
1079 for banner in banners {
1080 state.storage.remove(Some(banner)).await?;
1081 }
1082
1083 Ok(())
1084 }
1085}
1086
1087#[schema_extension_derive::extendible]
1088#[init_args(EggRepository, crate::State)]
1089#[hook_args(crate::State)]
1090#[derive(ToSchema, Serialize)]
1091#[schema(title = "EggRepository")]
1092pub struct AdminApiEggRepository {
1093 pub uuid: uuid::Uuid,
1094
1095 pub name: compact_str::CompactString,
1096 pub description: Option<compact_str::CompactString>,
1097 pub git_repository: compact_str::CompactString,
1098 pub credentials: EggRepositoryCredentials,
1099 pub default_nest: Option<super::nest::AdminApiNest>,
1100 pub auto_sync: bool,
1101
1102 pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
1103 pub created: chrono::DateTime<chrono::Utc>,
1104}