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 #[inline]
59 pub fn get_package_identifier(&self) -> String {
60 Self::convert_package_name_to_identifier(&self.package_name)
61 }
62
63 #[inline]
67 pub fn convert_package_name_to_identifier(package_name: &str) -> String {
68 package_name.replace('.', "_")
69 }
70
71 #[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 #[inline]
121 pub fn convert_identifier_to_package_name(identifier: &str) -> String {
122 identifier.replace('_', ".")
123 }
124
125 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(¤t) {
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 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 #[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 let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
769 ignore_builder.add(path.as_ref().join(".gitignore"));
770 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 #[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 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 #[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}