Skip to main content

shared/extensions/
distr.rs

1use anyhow::Context;
2use ignore::gitignore::GitignoreBuilder;
3use serde::{Deserialize, Serialize};
4use std::{
5    collections::BTreeMap,
6    io::{Read, Write},
7    path::Path,
8    sync::Arc,
9};
10use utoipa::ToSchema;
11use zip::write::FileOptions;
12
13#[derive(Clone, ToSchema, Deserialize, Serialize)]
14pub struct MetadataToml {
15    pub package_name: String,
16    pub name: String,
17    #[schema(value_type = String)]
18    pub panel_version: semver::VersionReq,
19
20    pub license_text: Option<String>,
21}
22
23impl MetadataToml {
24    /// Get the package identifier for this extension.
25    /// This is derived from the package name by replacing `.` with `_`.
26    ///
27    /// Example: `com.example.myextension` becomes `com_example_myextension`.
28    #[inline]
29    pub fn get_package_identifier(&self) -> String {
30        Self::convert_package_name_to_identifier(&self.package_name)
31    }
32
33    /// Convert a package name to an identifier by replacing `.` with `_`.
34    ///
35    /// Example: `com.example.myextension` becomes `com_example_myextension`.
36    #[inline]
37    pub fn convert_package_name_to_identifier(package_name: &str) -> String {
38        package_name.replace('.', "_")
39    }
40
41    /// Validate that a package identifier matches the same constraints as the package name
42    /// validation in `ExtensionDistrFile::validate`, but with `_` instead of `.` as the separator.
43    #[inline]
44    pub fn is_valid_package_identifier(identifier: &str) -> bool {
45        let mut segments = identifier.split('_');
46        let tld = segments.next();
47        let author = segments.next();
48        let ident = segments.next();
49
50        if segments.next().is_some() {
51            return false;
52        }
53
54        let Some(tld) = tld else { return false };
55        let Some(author) = author else { return false };
56        let Some(ident) = ident else { return false };
57
58        if !(2..=6).contains(&tld.len()) {
59            return false;
60        }
61        if !(3..=30).contains(&author.len()) {
62            return false;
63        }
64        if !(4..=30).contains(&ident.len()) {
65            return false;
66        }
67
68        if !tld.chars().all(|c| c.is_ascii_lowercase()) {
69            return false;
70        }
71        if !author
72            .chars()
73            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
74        {
75            return false;
76        }
77        if !ident
78            .chars()
79            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
80        {
81            return false;
82        }
83
84        true
85    }
86
87    /// Convert an identifier to a package name by replacing `_` with `.`.
88    ///
89    /// Example: `com_example_myextension` becomes `com.example.myextension`.
90    #[inline]
91    pub fn convert_identifier_to_package_name(identifier: &str) -> String {
92        identifier.replace('_', ".")
93    }
94}
95
96#[derive(Clone, Deserialize, Serialize)]
97pub struct CargoPackage {
98    pub description: String,
99    pub authors: Vec<String>,
100    pub version: semver::Version,
101}
102
103#[derive(Clone, Deserialize, Serialize)]
104pub struct CargoToml {
105    pub package: CargoPackage,
106    pub dependencies: BTreeMap<String, toml::Value>,
107}
108
109#[derive(Clone, Deserialize, Serialize)]
110pub struct PackageJson {
111    pub dependencies: BTreeMap<String, String>,
112}
113
114#[derive(Clone)]
115pub struct ExtensionMigration {
116    pub id: uuid::Uuid,
117    pub name: String,
118    pub date: chrono::DateTime<chrono::Utc>,
119    pub sql: String,
120    pub sql_down: String,
121}
122
123impl ExtensionMigration {
124    pub fn from_directory_raw(
125        path: &Path,
126        extension_identifier: &str,
127        mut content_up_raw: impl std::io::Read,
128        mut content_down_raw: impl std::io::Read,
129    ) -> Result<Self, std::io::Error> {
130        let mut content_up = String::new();
131        content_up_raw.read_to_string(&mut content_up)?;
132
133        let mut content_down = String::new();
134        content_down_raw.read_to_string(&mut content_down)?;
135
136        let name = path
137            .file_name()
138            .ok_or_else(|| {
139                std::io::Error::new(
140                    std::io::ErrorKind::InvalidData,
141                    format!(
142                        "invalid migration directory name `{}`: unable to extract directory name.",
143                        path.display()
144                    ),
145                )
146            })?
147            .to_string_lossy()
148            .to_string();
149
150        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
151            return Err(std::io::Error::new(
152                std::io::ErrorKind::InvalidData,
153                format!(
154                    "invalid migration directory name `{}`: must be ASCII and contain only alphanumeric characters or underscores.",
155                    path.display()
156                ),
157            ));
158        }
159
160        let date = name
161            .split('_')
162            .next()
163            .and_then(|date_str| {
164                chrono::NaiveDateTime::parse_from_str(date_str, "%Y%m%d%H%M%S")
165                    .ok()
166                    .map(|ndt| ndt.and_utc())
167            })
168            .ok_or_else(|| {
169                std::io::Error::new(
170                    std::io::ErrorKind::InvalidData,
171                    format!(
172                        "invalid migration directory name `{}`: unable to parse date from directory name. expected format `yyyymmddhhmmss_description/`.",
173                        path.display()
174                    ),
175                )
176            })?;
177
178        let xxh3 = xxhash_rust::xxh3::xxh3_128(
179            format!(
180                "{}:{}:{}",
181                extension_identifier,
182                name,
183                date.timestamp_millis()
184            )
185            .as_bytes(),
186        );
187        let id = uuid::Builder::from_u128(xxh3)
188            .with_variant(uuid::Variant::RFC4122)
189            .with_version(uuid::Version::Custom)
190            .into_uuid();
191
192        Ok(Self {
193            id,
194            name,
195            date,
196            sql: content_up,
197            sql_down: content_down,
198        })
199    }
200}
201
202#[derive(Clone)]
203pub struct ExtensionDistrFile {
204    zip: zip::ZipArchive<Arc<std::fs::File>>,
205
206    pub metadata_toml: MetadataToml,
207    pub cargo_toml: CargoToml,
208    pub package_json: PackageJson,
209}
210
211impl ExtensionDistrFile {
212    pub fn parse_from_reader(file: std::fs::File) -> Result<Self, anyhow::Error> {
213        let mut zip = zip::ZipArchive::new(Arc::new(file))?;
214
215        let mut metadata_toml = zip.by_name("Metadata.toml")?;
216        let mut metadata_toml_bytes = vec![0; metadata_toml.size() as usize];
217        metadata_toml.read_exact(&mut metadata_toml_bytes)?;
218        drop(metadata_toml);
219        let metadata_toml: MetadataToml = toml::from_slice(&metadata_toml_bytes)?;
220
221        let mut cargo_toml = zip.by_name("backend/Cargo.toml")?;
222        let mut cargo_toml_bytes = vec![0; cargo_toml.size() as usize];
223        cargo_toml.read_exact(&mut cargo_toml_bytes)?;
224        drop(cargo_toml);
225
226        let cargo_toml: CargoToml = toml::from_slice(&cargo_toml_bytes)?;
227
228        let mut package_json = zip.by_name("frontend/package.json")?;
229        let mut package_json_bytes = vec![0; package_json.size() as usize];
230        package_json.read_exact(&mut package_json_bytes)?;
231        drop(package_json);
232
233        let package_json: PackageJson = serde_json::from_slice(&package_json_bytes)?;
234
235        let mut this = Self {
236            zip,
237            metadata_toml,
238            cargo_toml,
239            package_json,
240        };
241        this.validate()?;
242
243        Ok(this)
244    }
245
246    pub fn extract_backend(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
247        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
248
249        let mut i = 0;
250        while let Ok(mut entry) = self.zip.by_index(i) {
251            i += 1;
252
253            if !entry.name().starts_with("backend/") {
254                continue;
255            }
256
257            let clean_path = match entry.enclosed_name() {
258                Some(clean_path) => clean_path,
259                None => continue,
260            };
261            let clean_path = match clean_path.strip_prefix("backend/") {
262                Ok(clean_path) => clean_path,
263                Err(_) => continue,
264            };
265
266            if entry.is_dir() {
267                filesystem.create_dir_all(clean_path)?;
268            } else if entry.is_file() {
269                let mut file = filesystem.create(clean_path)?;
270
271                std::io::copy(&mut entry, &mut file)?;
272                file.flush()?;
273                file.sync_all()?;
274            }
275        }
276
277        filesystem.write(
278            "Metadata.toml",
279            toml::to_string_pretty(&self.metadata_toml)?.into_bytes(),
280        )?;
281
282        Ok(())
283    }
284
285    pub fn extract_frontend(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
286        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
287
288        let mut i = 0;
289        while let Ok(mut entry) = self.zip.by_index(i) {
290            i += 1;
291
292            if !entry.name().starts_with("frontend/") {
293                continue;
294            }
295
296            let clean_path = match entry.enclosed_name() {
297                Some(clean_path) => clean_path,
298                None => continue,
299            };
300            let clean_path = match clean_path.strip_prefix("frontend/") {
301                Ok(clean_path) => clean_path,
302                Err(_) => continue,
303            };
304
305            if entry.is_dir() {
306                filesystem.create_dir_all(clean_path)?;
307            } else if entry.is_file() {
308                let mut file = filesystem.create(clean_path)?;
309
310                std::io::copy(&mut entry, &mut file)?;
311                file.flush()?;
312                file.sync_all()?;
313            }
314        }
315
316        Ok(())
317    }
318
319    pub fn has_migrations(&mut self) -> bool {
320        self.zip.by_name("migrations/").is_ok()
321    }
322
323    pub fn extract_migrations(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
324        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
325
326        let mut i = 0;
327        while let Ok(mut entry) = self.zip.by_index(i) {
328            i += 1;
329
330            if !entry.name().starts_with("migrations/") {
331                continue;
332            }
333
334            let clean_path = match entry.enclosed_name() {
335                Some(clean_path) => clean_path,
336                None => continue,
337            };
338            let clean_path = match clean_path.strip_prefix("migrations/") {
339                Ok(clean_path) => clean_path,
340                Err(_) => continue,
341            };
342
343            if entry.is_dir() {
344                filesystem.create_dir_all(clean_path)?;
345            } else if entry.is_file() {
346                let mut file = filesystem.create(clean_path)?;
347
348                std::io::copy(&mut entry, &mut file)?;
349                file.flush()?;
350                file.sync_all()?;
351            }
352        }
353
354        Ok(())
355    }
356
357    pub fn get_migrations(&mut self) -> Result<Vec<ExtensionMigration>, anyhow::Error> {
358        let mut migrations = Vec::new();
359
360        let mut migration_dirs = Vec::new();
361        let mut i = 0;
362        while let Ok(entry) = self.zip.by_index(i) {
363            i += 1;
364
365            let entry_name = entry.name().to_string();
366            if entry_name.starts_with("migrations/")
367                && entry_name.ends_with("/up.sql")
368                && !entry.is_dir()
369                && let Some(dir_name) = entry_name
370                    .strip_prefix("migrations/")
371                    .and_then(|s| s.strip_suffix("/up.sql"))
372            {
373                migration_dirs.push(dir_name.to_string());
374            }
375        }
376
377        for dir_name in migration_dirs {
378            let up_path = Path::new("migrations").join(&dir_name).join("up.sql");
379            let down_path = Path::new("migrations").join(&dir_name).join("down.sql");
380
381            let mut up_entry = self.zip.by_path(&up_path)?;
382            let mut up_bytes = vec![0; up_entry.size() as usize];
383            up_entry.read_exact(&mut up_bytes)?;
384            drop(up_entry);
385
386            let mut down_entry = self.zip.by_path(&down_path)?;
387            let mut down_bytes = vec![0; down_entry.size() as usize];
388            down_entry.read_exact(&mut down_bytes)?;
389            drop(down_entry);
390
391            let dir_path = Path::new(&dir_name);
392
393            migrations.push(ExtensionMigration::from_directory_raw(
394                dir_path,
395                &self.metadata_toml.get_package_identifier(),
396                &up_bytes[..],
397                &down_bytes[..],
398            )?);
399        }
400
401        Ok(migrations)
402    }
403
404    pub fn validate(&mut self) -> Result<(), anyhow::Error> {
405        const MUST_EXIST_DIRECTORIES: &[&str] =
406            &["backend/", "backend/src/", "frontend/", "frontend/src/"];
407        const MUST_EXIST_FILES: &[&str] = &[
408            "Metadata.toml",
409            "backend/Cargo.toml",
410            "backend/src/lib.rs",
411            "frontend/package.json",
412        ];
413
414        let mut package_segments = self.metadata_toml.package_name.split('.');
415        let tld_segment = package_segments.next().ok_or_else(|| {
416            anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
417        })?;
418        let author_segment = package_segments.next().ok_or_else(|| {
419            anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
420        })?;
421        let identifier_segment = package_segments.next().ok_or_else(|| {
422            anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
423        })?;
424
425        if package_segments.next().is_some() {
426            return Err(anyhow::anyhow!(
427                "invalid package name in calagopus extension archive. (too many segments, expected 3)"
428            ));
429        }
430
431        if tld_segment.len() < 2 || tld_segment.len() > 6 {
432            return Err(anyhow::anyhow!(
433                "invalid tld segment `{}` in calagopus extension archive package name.",
434                tld_segment
435            ));
436        }
437
438        if author_segment.len() < 3 || author_segment.len() > 30 {
439            return Err(anyhow::anyhow!(
440                "invalid author segment `{}` in calagopus extension archive package name.",
441                author_segment
442            ));
443        }
444
445        if identifier_segment.len() < 4 || identifier_segment.len() > 30 {
446            return Err(anyhow::anyhow!(
447                "invalid identifier segment `{}` in calagopus extension archive package name.",
448                identifier_segment
449            ));
450        }
451
452        for c in tld_segment.chars() {
453            if !c.is_ascii_lowercase() {
454                return Err(anyhow::anyhow!(
455                    "invalid character `{c}` in tld segment of calagopus extension archive package name."
456                ));
457            }
458        }
459
460        for c in author_segment.chars() {
461            if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
462                return Err(anyhow::anyhow!(
463                    "invalid character `{c}` in author segment of calagopus extension archive package name."
464                ));
465            }
466        }
467
468        for c in identifier_segment.chars() {
469            if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
470                return Err(anyhow::anyhow!(
471                    "invalid character `{c}` in identifier segment of calagopus extension archive package name."
472                ));
473            }
474        }
475
476        for dir in MUST_EXIST_DIRECTORIES {
477            if self.zip.by_name(dir).ok().is_none_or(|e| !e.is_dir()) {
478                return Err(anyhow::anyhow!(
479                    "unable to find directory `{dir}` in calagopus extension archive."
480                ));
481            }
482        }
483
484        for file in MUST_EXIST_FILES {
485            if self.zip.by_name(file).ok().is_none_or(|e| !e.is_file()) {
486                return Err(anyhow::anyhow!(
487                    "unable to find file `{file}` in calagopus extension archive."
488                ));
489            }
490        }
491
492        if self.zip.by_name("frontend/src/index.ts").is_err()
493            && self.zip.by_name("frontend/src/index.tsx").is_err()
494        {
495            return Err(anyhow::anyhow!(
496                "unable to find file `frontend/src/index.ts` or `frontend/src/index.tsx` in calagopus extension archive."
497            ));
498        }
499
500        {
501            let mut lib = self.zip.by_name("backend/src/lib.rs")?;
502            let mut lib_string = String::new();
503            lib_string.reserve_exact(lib.size() as usize);
504            lib.read_to_string(&mut lib_string)?;
505            drop(lib);
506
507            if !lib_string.contains("pub struct ExtensionStruct") {
508                return Err(anyhow::anyhow!(
509                    "unable to find `pub struct ExtensionStruct` in calagopus extension archive backend/src/lib.rs."
510                ));
511            }
512        }
513
514        {
515            let mut index = if let Ok(index) = self.zip.by_name("frontend/src/index.ts") {
516                index
517            } else {
518                self.zip.by_name("frontend/src/index.tsx")?
519            };
520            let mut index_string = String::new();
521            index_string.reserve_exact(index.size() as usize);
522            index.read_to_string(&mut index_string)?;
523            drop(index);
524
525            if !index_string.contains("export default ") {
526                return Err(anyhow::anyhow!(
527                    "unable to find `export default ` in calagopus extension archive frontend/src/index.ts."
528                ));
529            }
530        }
531
532        if self.has_migrations()
533            && let Err(err) = self.get_migrations()
534        {
535            return Err(anyhow::anyhow!(
536                "unable to parse migrations in calagopus extension archive. make sure they are formatted as directories `20260125115245_xxx_xxx/` containing `up.sql` and `down.sql`. {err}"
537            ));
538        }
539
540        Ok(())
541    }
542
543    #[inline]
544    pub fn total_size(&self) -> u128 {
545        self.zip.decompressed_size().unwrap_or_default()
546    }
547}
548
549pub struct SlimExtensionDistrFile {
550    pub metadata_toml: MetadataToml,
551    pub cargo_toml: CargoToml,
552    pub package_json: PackageJson,
553}
554
555impl SlimExtensionDistrFile {
556    pub fn parse_from_directory(path: impl AsRef<Path>) -> Result<Vec<Self>, anyhow::Error> {
557        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
558        let mut results = Vec::new();
559
560        let mut dir = filesystem.read_dir("backend-extensions")?;
561        while let Some(Ok((is_dir, name))) = dir.next_entry() {
562            if !is_dir || name == "internal-list" {
563                continue;
564            }
565
566            let metadata_toml = filesystem.read_to_string(
567                Path::new("backend-extensions")
568                    .join(&name)
569                    .join("Metadata.toml"),
570            )?;
571            let metadata_toml: MetadataToml = toml::from_str(&metadata_toml)?;
572
573            let cargo_toml = filesystem.read_to_string(
574                Path::new("backend-extensions")
575                    .join(&name)
576                    .join("Cargo.toml"),
577            )?;
578            let cargo_toml: CargoToml = toml::from_str(&cargo_toml)?;
579
580            let package_json = filesystem.read_to_string(
581                Path::new("frontend/extensions")
582                    .join(&name)
583                    .join("package.json"),
584            )?;
585            let package_json: PackageJson = serde_json::from_str(&package_json)?;
586
587            results.push(Self {
588                metadata_toml,
589                cargo_toml,
590                package_json,
591            });
592        }
593
594        Ok(results)
595    }
596}
597
598pub struct ExtensionDistrFileBuilder {
599    zip: zip::ZipWriter<std::fs::File>,
600    wrote_backend: bool,
601    wrote_frontend: bool,
602    wrote_migrations: bool,
603}
604
605impl ExtensionDistrFileBuilder {
606    pub fn new(file: std::fs::File) -> Self {
607        Self {
608            zip: zip::ZipWriter::new(file),
609            wrote_backend: false,
610            wrote_frontend: false,
611            wrote_migrations: false,
612        }
613    }
614
615    pub fn add_backend(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
616        if self.wrote_backend {
617            return Err(anyhow::anyhow!(
618                "Cannot write backend, it has already been written."
619            ));
620        }
621
622        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
623
624        let metadata_toml = filesystem
625            .read_to_string("Metadata.toml")
626            .context("Failed to read Metadata.toml from backend extension directory.")?;
627        self.zip
628            .start_file("Metadata.toml", FileOptions::<()>::default())?;
629        self.zip.write_all(metadata_toml.as_bytes())?;
630
631        self.zip.add_directory(
632            "backend",
633            FileOptions::<()>::default().compression_level(Some(9)),
634        )?;
635
636        // Respect the extension's own `.gitignore` and never bundle the git
637        // directory, the generated `Metadata.toml`, or the co-located frontend
638        // code and database migrations (exported separately).
639        let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
640        ignore_builder.add(path.as_ref().join(".gitignore"));
641        ignore_builder
642            .add_line(None, ".git/")?
643            .add_line(None, "Metadata.toml")?
644            .add_line(None, "/frontend/")?
645            .add_line(None, "/migrations/")?;
646        let ignored = &[ignore_builder.build()?];
647
648        let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
649        while let Some(Ok((_, name))) = walker.next_entry() {
650            let metadata = filesystem.metadata(&name)?;
651            let virtual_path = Path::new("backend").join(&name);
652            let virtual_path = virtual_path.to_string_lossy();
653            // zip entry names use forward slashes, but Windows joins with `\`
654            #[cfg(windows)]
655            let virtual_path = virtual_path.replace('\\', "/");
656
657            let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
658
659            if metadata.is_dir() {
660                self.zip.add_directory(&*virtual_path, options)?;
661            } else if metadata.is_file() {
662                self.zip.start_file(&*virtual_path, options)?;
663
664                let mut reader = filesystem.open(&name)?;
665                std::io::copy(&mut reader, &mut self.zip)?;
666            }
667        }
668
669        self.wrote_backend = true;
670
671        Ok(self)
672    }
673
674    pub fn add_frontend(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
675        if self.wrote_frontend {
676            return Err(anyhow::anyhow!(
677                "Cannot write frontend, it has already been written."
678            ));
679        }
680
681        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
682
683        self.zip.add_directory(
684            "frontend",
685            FileOptions::<()>::default().compression_level(Some(9)),
686        )?;
687
688        // Respect the extension's own `.gitignore` and never bundle the git
689        // directory, node_modules, or the `tsconfig.json` generated on install
690        // to make editors resolve the panel's TypeScript config.
691        let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
692        ignore_builder.add(path.as_ref().join(".gitignore"));
693        // `node_modules` is matched without a trailing slash so the symlink
694        // created on install (reported as a non-directory) is excluded too.
695        ignore_builder
696            .add_line(None, ".git/")?
697            .add_line(None, "node_modules")?
698            .add_line(None, "/tsconfig.json")?;
699        let ignored = &[ignore_builder.build()?];
700
701        let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
702        while let Some(Ok((_, name))) = walker.next_entry() {
703            let metadata = filesystem.metadata(&name)?;
704            let virtual_path = Path::new("frontend").join(&name);
705            let virtual_path = virtual_path.to_string_lossy();
706            // zip entry names use forward slashes, but Windows joins with `\`
707            #[cfg(windows)]
708            let virtual_path = virtual_path.replace('\\', "/");
709
710            let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
711
712            if metadata.is_dir() {
713                self.zip.add_directory(&*virtual_path, options)?;
714            } else if metadata.is_file() {
715                self.zip.start_file(&*virtual_path, options)?;
716
717                let mut reader = filesystem.open(&name)?;
718                std::io::copy(&mut reader, &mut self.zip)?;
719            }
720        }
721
722        self.wrote_frontend = true;
723
724        Ok(self)
725    }
726
727    pub fn add_migrations(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
728        if self.wrote_migrations {
729            return Err(anyhow::anyhow!(
730                "Cannot write migrations, they have already been written."
731            ));
732        }
733
734        let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
735
736        self.zip.add_directory(
737            "migrations",
738            FileOptions::<()>::default().compression_level(Some(9)),
739        )?;
740
741        // Respect the extension's own `.gitignore` and never bundle the git directory.
742        let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
743        ignore_builder.add(path.as_ref().join(".gitignore"));
744        ignore_builder.add_line(None, ".git/")?;
745        let ignored = &[ignore_builder.build()?];
746
747        let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
748        while let Some(Ok((_, name))) = walker.next_entry() {
749            let metadata = filesystem.metadata(&name)?;
750            let virtual_path = Path::new("migrations").join(&name);
751            let virtual_path = virtual_path.to_string_lossy();
752            // zip entry names use forward slashes, but Windows joins with `\`
753            #[cfg(windows)]
754            let virtual_path = virtual_path.replace('\\', "/");
755
756            let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
757
758            if metadata.is_dir() {
759                self.zip.add_directory(&*virtual_path, options)?;
760            } else if metadata.is_file() {
761                self.zip.start_file(&*virtual_path, options)?;
762
763                let mut reader = filesystem.open(&name)?;
764                std::io::copy(&mut reader, &mut self.zip)?;
765            }
766        }
767
768        self.wrote_migrations = true;
769
770        Ok(self)
771    }
772
773    pub fn write(mut self) -> std::io::Result<std::fs::File> {
774        if !self.wrote_backend {
775            return Err(std::io::Error::new(
776                std::io::ErrorKind::InvalidData,
777                "Cannot finish writing extension archive: backend files not written.",
778            ));
779        }
780
781        if !self.wrote_frontend {
782            return Err(std::io::Error::new(
783                std::io::ErrorKind::InvalidData,
784                "Cannot finish writing extension archive: frontend files not written.",
785            ));
786        }
787
788        self.zip.set_comment(format!(
789            "this .c7s.zip extension archive has been generated by calagopus@{}",
790            crate::VERSION
791        ))?;
792        let writer = self.zip.finish()?;
793
794        Ok(writer)
795    }
796}
797
798pub fn resync_extension_list() -> Result<(), anyhow::Error> {
799    let internal_list_extension = Path::new("backend-extensions/internal-list");
800    let extensions_path = Path::new("backend-extensions");
801
802    let mut packages = Vec::new();
803
804    for dir in std::fs::read_dir(extensions_path).unwrap().flatten() {
805        if !dir.file_type().unwrap().is_dir() || dir.file_name() == "internal-list" {
806            continue;
807        }
808
809        let metadata_toml = match std::fs::read_to_string(dir.path().join("Metadata.toml")) {
810            Ok(file) => file,
811            Err(_) => continue,
812        };
813
814        let cargo_toml = match std::fs::read_to_string(dir.path().join("Cargo.toml")) {
815            Ok(file) => file,
816            Err(_) => continue,
817        };
818
819        #[derive(Deserialize)]
820        struct MetadataToml {
821            package_name: String,
822            name: String,
823            panel_version: semver::VersionReq,
824        }
825
826        #[derive(Deserialize)]
827        struct CargoToml {
828            package: CargoPackage,
829        }
830
831        #[derive(Deserialize)]
832        struct CargoPackage {
833            description: Option<String>,
834            authors: Option<Vec<String>>,
835            version: semver::Version,
836        }
837
838        let metadata_toml: MetadataToml = toml::from_str(&metadata_toml).unwrap();
839        let cargo_toml: CargoToml = toml::from_str(&cargo_toml).unwrap();
840        packages.push((dir.file_name(), metadata_toml, cargo_toml.package));
841    }
842
843    std::fs::create_dir_all(internal_list_extension).unwrap();
844    std::fs::create_dir_all(internal_list_extension.join("src")).unwrap();
845
846    let mut deps = String::new();
847
848    for (path, metadata, _) in packages.iter() {
849        deps.push_str(&metadata.package_name.replace('.', "_"));
850        deps.push_str(" = { path = \"../");
851        deps.push_str(&path.to_string_lossy());
852        deps.push_str("\" }\n");
853    }
854
855    const CARGO_TEMPLATE_TOML: &str =
856        include_str!("../../../backend-extensions/internal-list/Cargo.template.toml");
857
858    std::fs::write(
859        internal_list_extension.join("Cargo.toml"),
860        format!("{CARGO_TEMPLATE_TOML}{}", deps),
861    )?;
862
863    let mut exts = String::new();
864
865    for (_, metadata, package) in packages {
866        exts.push_str(&format!(
867            r#"
868        ConstructedExtension {{
869            metadata_toml: MetadataToml {{
870                package_name: {}.to_string(),
871                name: {}.to_string(),
872                panel_version: semver::VersionReq::parse({}).unwrap(),
873                license_text: None,
874            }},
875            package_name: {},
876            description: {},
877            authors: &{},
878            version: semver::Version::parse({}).unwrap(),
879            extension: Arc::new({}::ExtensionStruct::default()),
880        }},"#,
881            toml::Value::String(metadata.package_name.clone()),
882            toml::Value::String(metadata.name),
883            toml::Value::String(metadata.panel_version.to_string()),
884            toml::Value::String(metadata.package_name.clone()),
885            toml::Value::String(package.description.unwrap_or_default()),
886            toml::Value::Array(
887                package
888                    .authors
889                    .unwrap_or_default()
890                    .into_iter()
891                    .map(toml::Value::String)
892                    .collect(),
893            ),
894            toml::Value::String(package.version.to_string()),
895            metadata.package_name.replace('.', "_"),
896        ));
897    }
898
899    let exts_vec = if exts.is_empty() {
900        "vec![]".to_string()
901    } else {
902        format!("vec![{}\n    ]", exts)
903    };
904
905    std::fs::write(
906        internal_list_extension.join("src/lib.rs"),
907        format!(
908            r#"#![allow(clippy::default_constructed_unit_structs)]
909#![allow(unused_imports)]
910
911use shared::extensions::{{ConstructedExtension, distr::MetadataToml}};
912use std::sync::Arc;
913
914pub fn list() -> Vec<ConstructedExtension> {{
915    {}
916}}
917"#,
918            exts_vec,
919        ),
920    )?;
921
922    Ok(())
923}