Skip to main content

pebble_cms/cli/
backup.rs

1use crate::cli::BackupCommand;
2use crate::Config;
3use anyhow::Result;
4use std::fs::{self, File};
5use std::io::{Read, Write};
6use std::path::Path;
7use zip::write::SimpleFileOptions;
8use zip::{ZipArchive, ZipWriter};
9
10pub async fn run(config_path: &Path, command: BackupCommand) -> Result<()> {
11    let config = Config::load(config_path)?;
12
13    match command {
14        BackupCommand::Create { output } => {
15            create_backup(&config, &output)?;
16        }
17        BackupCommand::Restore { file } => {
18            restore_backup(&file, &config)?;
19        }
20        BackupCommand::List { dir } => {
21            list_backups(&dir)?;
22        }
23    }
24
25    Ok(())
26}
27
28pub fn create_backup(config: &Config, output_dir: &Path) -> Result<()> {
29    fs::create_dir_all(output_dir)?;
30
31    let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
32    let backup_name = format!("pebble-backup-{}.zip", timestamp);
33    let backup_path = output_dir.join(&backup_name);
34
35    let file = File::create(&backup_path)?;
36    let mut zip = ZipWriter::new(file);
37    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
38
39    let db_path = Path::new(&config.database.path);
40    if db_path.exists() {
41        let mut db_data = Vec::new();
42        File::open(db_path)?.read_to_end(&mut db_data)?;
43        zip.start_file("pebble.db", options)?;
44        zip.write_all(&db_data)?;
45        tracing::info!("Added database: {} bytes", db_data.len());
46    }
47
48    let media_dir = Path::new(&config.media.upload_dir);
49    if media_dir.exists() {
50        let mut media_count = 0;
51        for entry in fs::read_dir(media_dir)? {
52            let entry = entry?;
53            let path = entry.path();
54            if path.is_file() {
55                let filename = path
56                    .file_name()
57                    .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?
58                    .to_string_lossy();
59                let archive_path = format!("media/{}", filename);
60
61                let mut file_data = Vec::new();
62                File::open(&path)?.read_to_end(&mut file_data)?;
63                zip.start_file(archive_path, options)?;
64                zip.write_all(&file_data)?;
65                media_count += 1;
66            }
67        }
68        tracing::info!("Added {} media files", media_count);
69    }
70
71    let manifest = serde_json::json!({
72        "version": env!("CARGO_PKG_VERSION"),
73        "created_at": chrono::Utc::now().to_rfc3339(),
74        "site_title": config.site.title,
75    });
76    zip.start_file("manifest.json", options)?;
77    zip.write_all(manifest.to_string().as_bytes())?;
78
79    zip.finish()?;
80    tracing::info!("Backup created: {}", backup_path.display());
81    Ok(())
82}
83
84fn restore_backup(archive_path: &Path, config: &Config) -> Result<()> {
85    if !archive_path.exists() {
86        anyhow::bail!("Backup file not found: {}", archive_path.display());
87    }
88
89    let file = File::open(archive_path)?;
90    let mut archive = ZipArchive::new(file)?;
91
92    let db_path = Path::new(&config.database.path);
93    let db_dir = db_path.parent().unwrap_or(Path::new("."));
94    fs::create_dir_all(db_dir)?;
95
96    let media_dir = Path::new(&config.media.upload_dir);
97    fs::create_dir_all(media_dir)?;
98
99    let canonical_db_dir = db_dir.canonicalize()?;
100    let canonical_media_dir = media_dir.canonicalize()?;
101
102    for i in 0..archive.len() {
103        let mut file = archive.by_index(i)?;
104        let name = file.name().to_string();
105
106        if name == "manifest.json" {
107            continue;
108        }
109
110        if name.contains("..") {
111            tracing::warn!("Skipping suspicious path in archive: {}", name);
112            continue;
113        }
114
115        let (outpath, canonical_base) = if name == "pebble.db" {
116            (db_path.to_path_buf(), &canonical_db_dir)
117        } else if name.starts_with("media/") {
118            let filename = name.strip_prefix("media/").unwrap_or(&name);
119            if filename.contains('/') || filename.contains('\\') {
120                tracing::warn!("Skipping nested media path: {}", name);
121                continue;
122            }
123            (media_dir.join(filename), &canonical_media_dir)
124        } else {
125            continue;
126        };
127
128        if let Some(parent) = outpath.parent() {
129            fs::create_dir_all(parent)?;
130        }
131
132        let canonical_outpath = if outpath.exists() {
133            outpath.canonicalize()?
134        } else if let Some(parent) = outpath.parent() {
135            parent
136                .canonicalize()?
137                .join(outpath.file_name().unwrap_or_default())
138        } else {
139            continue;
140        };
141
142        if !canonical_outpath.starts_with(canonical_base) {
143            tracing::warn!("Path traversal attempt blocked: {}", name);
144            continue;
145        }
146
147        let mut outfile = File::create(&outpath)?;
148        std::io::copy(&mut file, &mut outfile)?;
149        tracing::info!("Restored: {}", outpath.display());
150    }
151
152    tracing::info!("Backup restored from: {}", archive_path.display());
153    Ok(())
154}
155
156fn list_backups(dir: &Path) -> Result<()> {
157    if !dir.exists() {
158        tracing::info!("No backups directory found at {}", dir.display());
159        return Ok(());
160    }
161
162    let mut backups: Vec<_> = fs::read_dir(dir)?
163        .filter_map(|e| e.ok())
164        .filter(|e| {
165            e.path()
166                .extension()
167                .map(|ext| ext == "zip")
168                .unwrap_or(false)
169        })
170        .collect();
171
172    backups.sort_by_key(|e| e.path());
173    backups.reverse();
174
175    if backups.is_empty() {
176        tracing::info!("No backups found in {}", dir.display());
177        return Ok(());
178    }
179
180    println!("Available backups:");
181    for entry in backups {
182        let path = entry.path();
183        let metadata = fs::metadata(&path)?;
184        let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
185        if let Some(filename) = path.file_name() {
186            println!("  {} ({:.2} MB)", filename.to_string_lossy(), size_mb);
187        }
188    }
189
190    Ok(())
191}
192
193/// Enforce backup retention by removing the oldest backups beyond the keep count.
194pub fn enforce_retention(backup_dir: &Path, keep: usize) -> Result<()> {
195    if !backup_dir.exists() || keep == 0 {
196        return Ok(());
197    }
198
199    let mut backups: Vec<_> = fs::read_dir(backup_dir)?
200        .filter_map(|e| e.ok())
201        .filter(|e| {
202            let path = e.path();
203            path.extension().map(|ext| ext == "zip").unwrap_or(false)
204                && path
205                    .file_name()
206                    .and_then(|n| n.to_str())
207                    .map(|n| n.starts_with("pebble-backup-"))
208                    .unwrap_or(false)
209        })
210        .collect();
211
212    // Sort by filename (which includes timestamp) ascending
213    backups.sort_by_key(|e| e.path());
214
215    if backups.len() > keep {
216        let to_remove = backups.len() - keep;
217        for entry in backups.iter().take(to_remove) {
218            let path = entry.path();
219            if let Err(e) = fs::remove_file(&path) {
220                tracing::warn!("Failed to remove old backup {}: {}", path.display(), e);
221            } else {
222                tracing::info!("Removed old backup: {}", path.display());
223            }
224        }
225    }
226
227    Ok(())
228}