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
193pub 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 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}