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 sqlx::{Row, postgres::PgRow};
11use std::{
12 collections::BTreeMap,
13 path::PathBuf,
14 sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18fn validate_git_repository(
19 git_repository: &compact_str::CompactString,
20 _context: &(),
21) -> Result<(), garde::Error> {
22 let url = match gix::url::parse(git_repository.as_str()) {
23 Ok(url) => url,
24 Err(err) => return Err(garde::Error::new(format!("Invalid git repository: {err}"))),
25 };
26
27 match url.scheme {
28 gix::url::Scheme::Http | gix::url::Scheme::Https | gix::url::Scheme::Ssh => {}
29 _ => {
30 return Err(garde::Error::new(
31 "Invalid git repository: only http, https and ssh urls are supported, scp-style urls have to be written as ssh://user@host/path",
32 ));
33 }
34 }
35
36 if url.host().is_none() {
37 return Err(garde::Error::new("Invalid git repository: missing host"));
38 }
39
40 Ok(())
41}
42
43#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum EggRepositoryCredentials {
46 None,
47 Password {
48 #[garde(length(chars, min = 1, max = 255))]
49 #[schema(min_length = 1, max_length = 255)]
50 username: compact_str::CompactString,
51 #[garde(length(chars, min = 1, max = 255))]
52 #[schema(min_length = 1, max_length = 255)]
53 password: compact_str::CompactString,
54 },
55 PrivateKey {
56 #[garde(length(chars, min = 1, max = 255))]
57 #[schema(min_length = 1, max_length = 255)]
58 username: compact_str::CompactString,
59 #[garde(
60 length(chars, min = 1, max = 16384),
61 custom(crate::git::validate_private_key)
62 )]
63 #[schema(min_length = 1, max_length = 16384)]
64 private_key: String,
65 #[garde(inner(length(chars, min = 1, max = 255)))]
66 #[schema(min_length = 1, max_length = 255)]
67 passphrase: Option<compact_str::CompactString>,
68 },
69}
70
71impl EggRepositoryCredentials {
72 fn validate_scheme_for_url(&self, git_repository: &str) -> Result<(), anyhow::Error> {
73 let scheme = gix::url::parse(git_repository)
74 .map(|url| url.scheme)
75 .unwrap_or(gix::url::Scheme::Https);
76
77 match self {
78 EggRepositoryCredentials::None => {}
79 EggRepositoryCredentials::Password { .. } => {
80 if scheme == gix::url::Scheme::Http {
81 return Err(crate::response::DisplayError::new(
82 "password credentials cannot be sent over plain http, use an https repository url",
83 )
84 .into());
85 }
86 }
87 EggRepositoryCredentials::PrivateKey { .. } => {
88 if scheme != gix::url::Scheme::Ssh {
89 return Err(crate::response::DisplayError::new(
90 "private key credentials can only be used with ssh repositories",
91 )
92 .into());
93 }
94 }
95 }
96
97 Ok(())
98 }
99
100 fn validate_key_material(&self) -> Result<(), anyhow::Error> {
101 if let EggRepositoryCredentials::PrivateKey {
102 private_key,
103 passphrase,
104 ..
105 } = self
106 {
107 crate::git::parse_private_key(private_key, passphrase.as_deref()).map_err(|err| {
108 crate::response::DisplayError::new(format!("private key is unusable: {err}"))
109 })?;
110 }
111
112 Ok(())
113 }
114
115 pub async fn encrypt(
116 &mut self,
117 database: &crate::database::Database,
118 ) -> Result<(), anyhow::Error> {
119 match self {
120 EggRepositoryCredentials::None => {}
121 EggRepositoryCredentials::Password { password, .. } => {
122 *password = database.encrypt_base64(password.clone()).await?;
123 }
124 EggRepositoryCredentials::PrivateKey {
125 private_key,
126 passphrase,
127 ..
128 } => {
129 *private_key = database.encrypt_base64(private_key.clone()).await?.into();
130
131 if let Some(passphrase) = passphrase {
132 *passphrase = database.encrypt_base64(passphrase.clone()).await?;
133 }
134 }
135 }
136
137 Ok(())
138 }
139
140 pub async fn decrypt(
141 &mut self,
142 database: &crate::database::Database,
143 ) -> Result<(), anyhow::Error> {
144 match self {
145 EggRepositoryCredentials::None => {}
146 EggRepositoryCredentials::Password { password, .. } => {
147 if let Some(decrypted) = database.decrypt_base64_optional(&password).await? {
148 *password = decrypted;
149 }
150 }
151 EggRepositoryCredentials::PrivateKey {
152 private_key,
153 passphrase,
154 ..
155 } => {
156 if let Some(decrypted) = database.decrypt_base64_optional(&private_key).await? {
157 *private_key = decrypted.into();
158 }
159
160 if let Some(passphrase) = passphrase
161 && let Some(decrypted) = database.decrypt_base64_optional(&passphrase).await?
162 {
163 *passphrase = decrypted;
164 }
165 }
166 }
167
168 Ok(())
169 }
170}
171
172impl Censor for EggRepositoryCredentials {
173 fn censor(&mut self) {
174 match self {
175 EggRepositoryCredentials::None => {}
176 EggRepositoryCredentials::Password { password, .. } => {
177 *password = CENSORED_PLACEHOLDER.into();
178 }
179 EggRepositoryCredentials::PrivateKey {
180 private_key,
181 passphrase,
182 ..
183 } => {
184 *private_key = CENSORED_PLACEHOLDER.into();
185
186 if let Some(passphrase) = passphrase {
187 *passphrase = CENSORED_PLACEHOLDER.into();
188 }
189 }
190 }
191 }
192}
193
194impl From<EggRepositoryCredentials> for crate::git::GitCredentials {
195 fn from(value: EggRepositoryCredentials) -> Self {
196 match value {
197 EggRepositoryCredentials::None => Self::None,
198 EggRepositoryCredentials::Password { username, password } => {
199 Self::Password { username, password }
200 }
201 EggRepositoryCredentials::PrivateKey {
202 username,
203 private_key,
204 passphrase,
205 } => Self::PrivateKey {
206 username,
207 private_key,
208 passphrase,
209 },
210 }
211 }
212}
213
214#[derive(Serialize, Deserialize, Clone)]
215pub struct EggRepository {
216 pub uuid: uuid::Uuid,
217
218 pub name: compact_str::CompactString,
219 pub description: Option<compact_str::CompactString>,
220 pub git_repository: compact_str::CompactString,
221 pub credentials: EggRepositoryCredentials,
222
223 pub last_synced: Option<chrono::NaiveDateTime>,
224 pub created: chrono::NaiveDateTime,
225
226 extension_data: super::ModelExtensionData,
227}
228
229impl BaseModel for EggRepository {
230 const NAME: &'static str = "egg_repository";
231
232 fn get_extension_list() -> &'static super::ModelExtensionList {
233 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
234 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
235
236 &EXTENSIONS
237 }
238
239 fn get_extension_data(&self) -> &super::ModelExtensionData {
240 &self.extension_data
241 }
242
243 #[inline]
244 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
245 let prefix = prefix.unwrap_or_default();
246
247 BTreeMap::from([
248 (
249 "egg_repositories.uuid",
250 compact_str::format_compact!("{prefix}uuid"),
251 ),
252 (
253 "egg_repositories.name",
254 compact_str::format_compact!("{prefix}name"),
255 ),
256 (
257 "egg_repositories.description",
258 compact_str::format_compact!("{prefix}description"),
259 ),
260 (
261 "egg_repositories.git_repository",
262 compact_str::format_compact!("{prefix}git_repository"),
263 ),
264 (
265 "egg_repositories.credentials",
266 compact_str::format_compact!("{prefix}credentials"),
267 ),
268 (
269 "egg_repositories.last_synced",
270 compact_str::format_compact!("{prefix}last_synced"),
271 ),
272 (
273 "egg_repositories.created",
274 compact_str::format_compact!("{prefix}created"),
275 ),
276 ])
277 }
278
279 #[inline]
280 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
281 let prefix = prefix.unwrap_or_default();
282
283 Ok(Self {
284 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
285 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
286 description: row
287 .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
288 git_repository: row
289 .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
290 credentials: serde_json::from_value(
291 row.try_get(compact_str::format_compact!("{prefix}credentials").as_str())?,
292 )?,
293 last_synced: row
294 .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
295 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
296 extension_data: Self::map_extensions(prefix, row)?,
297 })
298 }
299}
300
301impl EggRepository {
302 pub async fn all_with_pagination(
303 database: &crate::database::Database,
304 page: i64,
305 per_page: i64,
306 search: Option<&str>,
307 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
308 let offset = (page - 1) * per_page;
309
310 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
311 r#"
312 SELECT {}, COUNT(*) OVER() AS total_count
313 FROM egg_repositories
314 WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
315 ORDER BY egg_repositories.created
316 LIMIT $2 OFFSET $3
317 "#,
318 Self::columns_sql(None)
319 )))
320 .bind(search)
321 .bind(per_page)
322 .bind(offset)
323 .fetch_all(database.read())
324 .await?;
325
326 Ok(super::Pagination {
327 total: rows
328 .first()
329 .map_or(Ok(0), |row| row.try_get("total_count"))?,
330 per_page,
331 page,
332 data: rows
333 .into_iter()
334 .map(|row| Self::map(None, &row))
335 .try_collect_vec()?,
336 })
337 }
338
339 pub async fn sync(&self, state: &crate::State) -> Result<usize, anyhow::Error> {
340 let git_repository = self.git_repository.clone();
341
342 let mut credentials = self.credentials.clone();
343 credentials.decrypt(&state.database).await?;
344
345 let url = gix::url::parse(git_repository.as_str())?;
346 crate::git::resolve_addresses(&state.env, &url).await?;
347
348 let env = Arc::clone(&state.env);
349
350 struct FoundEgg {
351 path: PathBuf,
352 readme: Option<String>,
353 exported_egg: super::nest_egg::ExportedNestEgg,
354 updated: chrono::DateTime<chrono::Utc>,
355 }
356
357 let exported_eggs =
358 tokio::task::spawn_blocking(move || -> Result<Vec<FoundEgg>, anyhow::Error> {
359 let mut exported_eggs = Vec::new();
360 let temp_dir = tempfile::tempdir()?;
361 let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
362
363 let mut prepare_fetch = gix::clone::PrepareFetch::new(
364 url.clone(),
365 temp_dir.path(),
366 gix::create::Kind::WithWorktree,
367 Default::default(),
368 gix::open::Options::default().config_overrides(["credential.helper="]),
369 )?
370 .configure_connection(
371 crate::git::GitCredentials::from(credentials)
372 .into_connection_configurator(env, url),
373 );
374
375 let (mut prepare_checkout, _) = prepare_fetch
376 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
377 let _ = prepare_checkout
378 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
379
380 tracing::info!(
381 "cloned egg repository {} to temporary directory",
382 git_repository
383 );
384
385 let mut walker = filesystem.walk_dir(".")?;
386 while let Some(Ok((is_dir, entry))) = walker.next_entry() {
387 if is_dir
388 || !matches!(
389 entry.extension().and_then(|s| s.to_str()),
390 Some("json") | Some("yml") | Some("yaml")
391 )
392 {
393 continue;
394 }
395
396 let metadata = match filesystem.metadata(&entry) {
397 Ok(metadata) => metadata,
398 Err(_) => continue,
399 };
400
401 if !metadata.is_file()
402 || metadata.len() > super::nest_egg::NestEgg::MAX_EXPORTED_SIZE as u64
403 {
404 continue;
405 }
406
407 let file_content = match filesystem.read_to_string(&entry) {
408 Ok(content) => content,
409 Err(_) => continue,
410 };
411 let exported_egg: super::nest_egg::ExportedNestEgg =
412 if entry.extension().and_then(|s| s.to_str()) == Some("json") {
413 match serde_json::from_str(file_content.trim()) {
414 Ok(egg) => egg,
415 Err(_) => continue,
416 }
417 } else {
418 match serde_norway::from_str(file_content.trim()) {
419 Ok(egg) => egg,
420 Err(_) => continue,
421 }
422 };
423
424 let mut readme = None;
425 let mut current_path = entry.parent();
426 'readme: while let Some(path) = current_path {
427 let mut dir = filesystem.read_dir(path)?;
428
429 while let Some(Ok((is_dir, entry))) = dir.next_entry() {
430 if is_dir {
431 continue;
432 }
433
434 let path = path.join(&entry);
435
436 if entry.to_lowercase().contains("readme")
437 && filesystem
438 .metadata(&path)
439 .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
440 && let Ok(content) = filesystem.read_to_string(&path)
441 {
442 readme = Some(content);
443 break 'readme;
444 }
445 }
446
447 current_path = path.parent();
448 }
449
450 exported_eggs.push(FoundEgg {
451 path: entry,
452 readme,
453 exported_egg,
454 updated: chrono::DateTime::from_timestamp(
455 metadata
456 .modified()
457 .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
458 .duration_since(std::time::UNIX_EPOCH)
459 .unwrap_or_default()
460 .as_secs() as i64,
461 0,
462 )
463 .unwrap_or_else(chrono::Utc::now),
464 });
465 }
466
467 drop(prepare_fetch);
468
469 Ok(exported_eggs)
470 })
471 .await??;
472
473 super::egg_repository_egg::EggRepositoryEgg::delete_unused(
474 &state.database,
475 self.uuid,
476 &exported_eggs
477 .iter()
478 .map(|egg| egg.path.to_string_lossy().to_compact_string())
479 .collect::<Vec<_>>(),
480 )
481 .await?;
482
483 let mut futures = Vec::new();
484 futures.reserve_exact(exported_eggs.len());
485
486 for egg in exported_eggs.iter() {
487 futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
488 &state.database,
489 self.uuid,
490 egg.path.to_string_lossy(),
491 egg.readme.as_deref(),
492 &egg.exported_egg,
493 egg.updated.naive_utc(),
494 ));
495 }
496
497 let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
498 while let Some(result) = results_stream.next().await {
499 result?;
500 }
501
502 sqlx::query(
503 r#"
504 UPDATE egg_repositories
505 SET last_synced = NOW()
506 WHERE egg_repositories.uuid = $1
507 "#,
508 )
509 .bind(self.uuid)
510 .execute(state.database.write())
511 .await?;
512
513 Ok(exported_eggs.len())
514 }
515}
516
517#[async_trait::async_trait]
518impl IntoAdminApiObject for EggRepository {
519 type AdminApiObject = AdminApiEggRepository;
520 type ExtraArgs<'a> = ();
521
522 async fn into_admin_api_object<'a>(
523 mut self,
524 state: &crate::State,
525 _args: Self::ExtraArgs<'a>,
526 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
527 let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
528
529 self.credentials.censor();
530
531 let api_object = finish_extendible!(
532 AdminApiEggRepository {
533 uuid: self.uuid,
534 name: self.name,
535 description: self.description,
536 git_repository: self.git_repository,
537 credentials: self.credentials,
538 last_synced: self.last_synced.map(|dt| dt.and_utc()),
539 created: self.created.and_utc(),
540 },
541 api_object,
542 state
543 )?;
544
545 Ok(api_object)
546 }
547}
548
549#[derive(ToSchema, Deserialize, Validate)]
550pub struct CreateEggRepositoryOptions {
551 #[garde(length(chars, min = 1, max = 255))]
552 #[schema(min_length = 1, max_length = 255)]
553 pub name: compact_str::CompactString,
554 #[garde(length(max = 1024))]
555 #[schema(max_length = 1024)]
556 pub description: Option<compact_str::CompactString>,
557 #[garde(custom(validate_git_repository))]
558 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
559 pub git_repository: compact_str::CompactString,
560 #[garde(dive)]
561 pub credentials: Option<EggRepositoryCredentials>,
562}
563
564#[async_trait::async_trait]
565impl CreatableModel for EggRepository {
566 type CreateOptions<'a> = CreateEggRepositoryOptions;
567 type CreateResult = Self;
568
569 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
570 static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
571 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
572
573 &CREATE_LISTENERS
574 }
575
576 async fn create_with_transaction(
577 state: &crate::State,
578 mut options: Self::CreateOptions<'_>,
579 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
580 ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
581 options.validate()?;
582
583 let mut query_builder = InsertQueryBuilder::new("egg_repositories");
584
585 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
586
587 let credentials = options
588 .credentials
589 .get_or_insert(EggRepositoryCredentials::None);
590 credentials.validate_scheme_for_url(&options.git_repository)?;
591 credentials.validate_key_material()?;
592 credentials.encrypt(&state.database).await?;
593
594 query_builder
595 .set("name", &options.name)
596 .set("description", &options.description)
597 .set("git_repository", &options.git_repository)
598 .set("credentials", serde_json::to_value(&credentials)?);
599
600 let row = query_builder
601 .returning(&Self::columns_sql(None))
602 .fetch_one(&mut **transaction)
603 .await?;
604 let mut egg_repository = Self::map(None, &row)?;
605
606 Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
607
608 Ok(egg_repository)
609 }
610}
611
612#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
613pub struct UpdateEggRepositoryOptions {
614 #[garde(length(chars, min = 1, max = 255))]
615 #[schema(min_length = 1, max_length = 255)]
616 pub name: Option<compact_str::CompactString>,
617 #[garde(length(max = 1024))]
618 #[schema(max_length = 1024)]
619 #[serde(
620 default,
621 skip_serializing_if = "Option::is_none",
622 with = "::serde_with::rust::double_option"
623 )]
624 pub description: Option<Option<compact_str::CompactString>>,
625 #[garde(inner(custom(validate_git_repository)))]
626 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
627 pub git_repository: Option<compact_str::CompactString>,
628 #[garde(dive)]
629 pub credentials: Option<EggRepositoryCredentials>,
630}
631
632#[async_trait::async_trait]
633impl UpdatableModel for EggRepository {
634 type UpdateOptions = UpdateEggRepositoryOptions;
635
636 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
637 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
638 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
639
640 &UPDATE_LISTENERS
641 }
642
643 async fn update_with_transaction(
644 &mut self,
645 state: &crate::State,
646 mut options: Self::UpdateOptions,
647 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
648 ) -> Result<(), crate::database::DatabaseError> {
649 options.validate()?;
650
651 let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
652
653 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
654 .await?;
655
656 let git_repository = options
657 .git_repository
658 .as_deref()
659 .unwrap_or(&self.git_repository);
660
661 match &mut options.credentials {
662 Some(credentials) => {
663 credentials.validate_scheme_for_url(git_repository)?;
664 credentials.validate_key_material()?;
665 credentials.encrypt(&state.database).await?;
666 }
667 None => self.credentials.validate_scheme_for_url(git_repository)?,
668 }
669
670 query_builder
671 .set("name", options.name.as_ref())
672 .set(
673 "description",
674 options.description.as_ref().map(|d| d.as_ref()),
675 )
676 .set("git_repository", options.git_repository.as_ref())
677 .set(
678 "credentials",
679 options
680 .credentials
681 .as_ref()
682 .map(serde_json::to_value)
683 .transpose()?,
684 )
685 .where_eq("uuid", self.uuid);
686
687 query_builder.execute(&mut **transaction).await?;
688
689 if let Some(name) = options.name {
690 self.name = name;
691 }
692 if let Some(description) = options.description {
693 self.description = description;
694 }
695 if let Some(git_repository) = options.git_repository {
696 self.git_repository = git_repository;
697 }
698 if let Some(credentials) = options.credentials {
699 self.credentials = credentials;
700 }
701
702 self.run_after_update_handlers(state, transaction).await?;
703
704 Ok(())
705 }
706}
707
708#[async_trait::async_trait]
709impl ByUuid for EggRepository {
710 async fn by_uuid(
711 database: &crate::database::Database,
712 uuid: uuid::Uuid,
713 ) -> Result<Self, crate::database::DatabaseError> {
714 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
715 r#"
716 SELECT {}
717 FROM egg_repositories
718 WHERE egg_repositories.uuid = $1
719 "#,
720 Self::columns_sql(None)
721 )))
722 .bind(uuid)
723 .fetch_one(database.read())
724 .await?;
725
726 Self::map(None, &row)
727 }
728
729 async fn by_uuid_with_transaction(
730 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
731 uuid: uuid::Uuid,
732 ) -> Result<Self, crate::database::DatabaseError> {
733 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
734 r#"
735 SELECT {}
736 FROM egg_repositories
737 WHERE egg_repositories.uuid = $1
738 "#,
739 Self::columns_sql(None)
740 )))
741 .bind(uuid)
742 .fetch_one(&mut **transaction)
743 .await?;
744
745 Self::map(None, &row)
746 }
747}
748
749#[async_trait::async_trait]
750impl DeletableModel for EggRepository {
751 type DeleteOptions = ();
752
753 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
754 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
755 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
756
757 &DELETE_LISTENERS
758 }
759
760 async fn delete_with_transaction(
761 &self,
762 state: &crate::State,
763 options: Self::DeleteOptions,
764 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
765 ) -> Result<(), anyhow::Error> {
766 self.run_delete_handlers(&options, state, transaction)
767 .await?;
768
769 sqlx::query(
770 r#"
771 DELETE FROM egg_repositories
772 WHERE egg_repositories.uuid = $1
773 "#,
774 )
775 .bind(self.uuid)
776 .execute(&mut **transaction)
777 .await?;
778
779 self.run_after_delete_handlers(&options, state, transaction)
780 .await?;
781
782 Ok(())
783 }
784}
785
786#[schema_extension_derive::extendible]
787#[init_args(EggRepository, crate::State)]
788#[hook_args(crate::State)]
789#[derive(ToSchema, Serialize)]
790#[schema(title = "EggRepository")]
791pub struct AdminApiEggRepository {
792 pub uuid: uuid::Uuid,
793
794 pub name: compact_str::CompactString,
795 pub description: Option<compact_str::CompactString>,
796 pub git_repository: compact_str::CompactString,
797 pub credentials: EggRepositoryCredentials,
798
799 pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
800 pub created: chrono::DateTime<chrono::Utc>,
801}