Skip to main content

laterite_web/theme/
archive.rs

1//! A theme as a zip: `theme.yaml`, `settings.json` when the theme has values,
2//! and one file per template under its kind's folder (`layouts/default.htm`,
3//! `pages/home.htm`, `partials/nav.htm`, `content/intro.md`,
4//! `assets/css/site.css`). A template with settings carries them as YAML
5//! above a `==` line, the markup below it; one without is the markup alone.
6//!
7//! The rows stay the source of truth: an export is for version control and
8//! for moving a theme between installs, and an import writes rows through
9//! [`store::save_template`], so every replaced template keeps a revision.
10
11use super::{engine, store, Kind, Theme, ThemeConfig, ThemeError};
12use laterite_core::Db;
13use std::io::{Cursor, Read, Write};
14use zip::write::SimpleFileOptions;
15
16/// The line between a template file's settings and its markup.
17const SEPARATOR: &str = "==";
18/// The most an archive may hold once read, so a hostile zip cannot exhaust memory.
19const MAX_BYTES: usize = 32 * 1024 * 1024;
20
21/// What an import did.
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct ImportReport {
24    /// Whether the theme was created by this import.
25    pub created: bool,
26    /// Templates written, new or replaced.
27    pub imported: usize,
28    /// Templates left as they were, because they existed and `replace` was off.
29    pub skipped: usize,
30    /// Files the archive held that are not templates, by name.
31    pub ignored: Vec<String>,
32}
33
34/// A template as its file holds it.
35pub fn file_body(settings: &serde_json::Value, markup: &str) -> String {
36    match settings.as_object() {
37        Some(map) if !map.is_empty() => {
38            let yaml = serde_saphyr::to_string(settings).unwrap_or_default();
39            format!("{}\n{SEPARATOR}\n{markup}", yaml.trim_end())
40        }
41        _ => markup.to_string(),
42    }
43}
44
45/// A file's settings and markup, split at the first `==` line when there is
46/// one and the part above it is YAML.
47pub fn parse_body(body: &str) -> Result<(serde_json::Value, String), ThemeError> {
48    let body = body.replace("\r\n", "\n");
49    let Some(at) = body
50        .split_inclusive('\n')
51        .scan(0usize, |offset, line| {
52            let start = *offset;
53            *offset += line.len();
54            Some((start, line))
55        })
56        .find(|(_, line)| line.trim_end() == SEPARATOR)
57        .map(|(start, _)| start)
58    else {
59        return Ok((serde_json::json!({}), body));
60    };
61    let settings: serde_json::Value = serde_saphyr::from_str(&body[..at])
62        .map_err(|e| ThemeError::Config(format!("the settings above `==` do not parse: {e}")))?;
63    if !settings.is_object() && !settings.is_null() {
64        return Err(ThemeError::Config(
65            "the settings above `==` must be a mapping".into(),
66        ));
67    }
68    let settings = if settings.is_null() {
69        serde_json::json!({})
70    } else {
71        settings
72    };
73    let rest = &body[at..];
74    let markup = rest.split_once('\n').map(|(_, m)| m).unwrap_or("");
75    Ok((settings, markup.to_string()))
76}
77
78/// The theme `code` as a zip.
79pub async fn export(db: &Db, code: &str) -> Result<Vec<u8>, ThemeError> {
80    let theme = store::find_theme(db, code)
81        .await?
82        .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
83    let templates = store::list_templates(db, theme.id, None).await?;
84    let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
85    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
86    zip.start_file("theme.yaml", options)?;
87    zip.write_all(theme.config_source.as_bytes())?;
88    if theme
89        .settings
90        .as_object()
91        .is_some_and(|map| !map.is_empty())
92    {
93        zip.start_file("settings.json", options)?;
94        zip.write_all(
95            serde_json::to_string_pretty(&theme.settings)
96                .unwrap_or_default()
97                .as_bytes(),
98        )?;
99    }
100    for t in templates {
101        zip.start_file(engine::template_name(t.kind, &t.path), options)?;
102        zip.write_all(file_body(&t.settings, &t.markup).as_bytes())?;
103    }
104    Ok(zip.finish()?.into_inner())
105}
106
107/// The kind a folder holds.
108fn kind_of(folder: &str) -> Option<Kind> {
109    Kind::ALL
110        .into_iter()
111        .find(|k| engine::template_name(*k, "") == format!("{folder}/"))
112}
113
114/// Imports `bytes` into the theme `code`, creating it from the archive's
115/// `theme.yaml` when it does not exist. An existing template is replaced only
116/// with `replace`, each replacement keeping a revision for `saved_by`; the
117/// config is replaced on the same condition. A locked theme refuses.
118pub async fn import(
119    db: &Db,
120    code: &str,
121    bytes: &[u8],
122    replace: bool,
123    saved_by: Option<i64>,
124) -> Result<ImportReport, ThemeError> {
125    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
126    let mut config: Option<String> = None;
127    let mut settings: Option<serde_json::Value> = None;
128    let mut files: Vec<(Kind, String, String)> = Vec::new();
129    let mut report = ImportReport::default();
130    let mut total = 0usize;
131    for i in 0..archive.len() {
132        let mut file = archive.by_index(i)?;
133        if file.is_dir() {
134            continue;
135        }
136        let Some(path) = file.enclosed_name() else {
137            report.ignored.push(file.name()?.to_string());
138            continue;
139        };
140        let name = path.to_string_lossy().replace('\\', "/");
141        let mut body = String::new();
142        total += file.size() as usize;
143        if total > MAX_BYTES {
144            return Err(ThemeError::Config("the archive is too large".into()));
145        }
146        file.read_to_string(&mut body)
147            .map_err(|_| ThemeError::Config(format!("{name} is not UTF-8 text")))?;
148        match name.as_str() {
149            "theme.yaml" => config = Some(body),
150            "settings.json" => {
151                settings = Some(serde_json::from_str(&body).map_err(|e| {
152                    ThemeError::Config(format!("settings.json does not parse: {e}"))
153                })?)
154            }
155            _ => match name.split_once('/') {
156                Some((folder, rest)) if !rest.is_empty() => match kind_of(folder) {
157                    Some(kind) => files.push((kind, rest.to_string(), body)),
158                    None => report.ignored.push(name),
159                },
160                _ => report.ignored.push(name),
161            },
162        }
163    }
164    let theme: Theme = match store::find_theme(db, code).await? {
165        Some(theme) => {
166            if theme.locked {
167                return Err(ThemeError::Locked(code.to_string()));
168            }
169            match (&config, replace) {
170                (Some(yaml), true) => store::update_theme_config(db, code, yaml).await?,
171                _ => theme,
172            }
173        }
174        None => {
175            let yaml = config.clone().ok_or_else(|| {
176                ThemeError::Config("the archive has no theme.yaml to create the theme from".into())
177            })?;
178            ThemeConfig::parse(&yaml)?;
179            report.created = true;
180            store::create_theme(db, code, &yaml).await?
181        }
182    };
183    for (kind, path, body) in files {
184        if !super::valid_path(&path) {
185            return Err(ThemeError::Path(path));
186        }
187        let exists = store::find_template(db, theme.id, kind, &path)
188            .await?
189            .is_some();
190        if exists && !replace {
191            report.skipped += 1;
192            continue;
193        }
194        let (settings, markup) = parse_body(&body)?;
195        store::save_template(db, theme.id, kind, &path, Some(settings), &markup, saved_by).await?;
196        report.imported += 1;
197    }
198    if let Some(values) = settings {
199        if report.created || replace {
200            store::save_settings(db, code, &values).await?;
201        }
202    }
203    Ok(report)
204}