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