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