Skip to main content

shared/models/
egg_repository.rs

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
104/// Stored credentials are only ever sent back to the scheme, host and port they were entered for.
105fn 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(&current_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}