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 #[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(
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 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 #[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 let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
828 ignore_builder.add(path.as_ref().join(".gitignore"));
829 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 #[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 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 #[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}