ngdp_client/commands/
keys.rs

1use crate::OutputFormat;
2use clap::Subcommand;
3use serde_json;
4use std::fs;
5use std::path::PathBuf;
6use tracing::{info, warn};
7
8#[derive(Debug, Subcommand)]
9pub enum KeysCommands {
10    /// Update the encryption key database from GitHub
11    Update {
12        /// Custom output path for the key file
13        #[arg(short, long)]
14        output: Option<PathBuf>,
15
16        /// Force update even if local file is recent
17        #[arg(short, long)]
18        force: bool,
19    },
20
21    /// Show current key database status
22    Status,
23}
24
25pub async fn handle_keys_command(
26    command: KeysCommands,
27    format: OutputFormat,
28) -> Result<(), Box<dyn std::error::Error>> {
29    match command {
30        KeysCommands::Update { output, force } => update_keys(output, force, format).await,
31        KeysCommands::Status => show_key_status(format),
32    }
33}
34
35async fn update_keys(
36    output: Option<PathBuf>,
37    force: bool,
38    format: OutputFormat,
39) -> Result<(), Box<dyn std::error::Error>> {
40    // Default path: ~/.config/cascette/WoW.txt
41    let output_path = output.unwrap_or_else(|| {
42        let config_dir = dirs::config_dir()
43            .unwrap_or_else(|| PathBuf::from("."))
44            .join("cascette");
45        config_dir.join("WoW.txt")
46    });
47
48    // Check if we should update
49    if !force && output_path.exists() {
50        let metadata = fs::metadata(&output_path)?;
51        if let Ok(modified) = metadata.modified() {
52            let age = std::time::SystemTime::now()
53                .duration_since(modified)
54                .unwrap_or_default();
55
56            // Skip if file is less than 24 hours old
57            if age.as_secs() < 86400 {
58                info!(
59                    "Key file is recent ({}h old), skipping update. Use --force to override.",
60                    age.as_secs() / 3600
61                );
62                return show_key_status(format);
63            }
64        }
65    }
66
67    info!("Downloading latest TACTKeys from GitHub...");
68
69    // Download from GitHub
70    let url = "https://raw.githubusercontent.com/wowdev/TACTKeys/master/WoW.txt";
71    let client = reqwest::Client::new();
72    let response = client.get(url).send().await?;
73
74    if !response.status().is_success() {
75        return Err(format!("Failed to download keys: HTTP {}", response.status()).into());
76    }
77
78    let content = response.text().await?;
79
80    info!("Processing key file...");
81
82    // Count valid keys
83    let mut key_count = 0;
84    let mut new_keys = 0;
85    let existing_keys = if output_path.exists() {
86        fs::read_to_string(&output_path).ok()
87    } else {
88        None
89    };
90
91    // Parse and validate keys
92    let mut valid_lines = Vec::new();
93    for line in content.lines() {
94        let line = line.trim();
95
96        // Skip comments and empty lines
97        if line.is_empty() || line.starts_with('#') {
98            valid_lines.push(line.to_string());
99            continue;
100        }
101
102        // Parse key line (format: keyname keyhex [description])
103        let parts: Vec<&str> = line.split_whitespace().collect();
104        if parts.len() >= 2 {
105            // Validate key name (16 hex chars)
106            if parts[0].len() == 16 && parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
107                // Validate key value (32 hex chars)
108                if parts[1].len() == 32 && parts[1].chars().all(|c| c.is_ascii_hexdigit()) {
109                    key_count += 1;
110
111                    // Check if this is a new key
112                    if let Some(ref existing) = existing_keys {
113                        if !existing.contains(parts[0]) {
114                            new_keys += 1;
115                        }
116                    } else {
117                        new_keys += 1;
118                    }
119
120                    valid_lines.push(line.to_string());
121                } else {
122                    warn!("Invalid key value for {}: {}", parts[0], parts[1]);
123                }
124            } else {
125                warn!("Invalid key name: {}", parts[0]);
126            }
127        }
128    }
129
130    info!("Writing key file...");
131
132    // Ensure directory exists
133    if let Some(parent) = output_path.parent() {
134        fs::create_dir_all(parent)?;
135    }
136
137    // Write to file
138    fs::write(&output_path, valid_lines.join("\n"))?;
139
140    // Report results
141    if new_keys > 0 {
142        info!(
143            "✅ Updated key database: {} total keys ({} new)",
144            key_count, new_keys
145        );
146    } else {
147        info!("✅ Key database is up to date: {} total keys", key_count);
148    }
149    info!("📁 Key file saved to: {}", output_path.display());
150
151    Ok(())
152}
153
154fn show_key_status(format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
155    let config_dir = dirs::config_dir()
156        .unwrap_or_else(|| PathBuf::from("."))
157        .join("cascette");
158    let key_file = config_dir.join("WoW.txt");
159
160    if !key_file.exists() {
161        warn!("No key file found at {}", key_file.display());
162        info!("Run 'ngdp keys update' to download the latest keys");
163        return Ok(());
164    }
165
166    let content = fs::read_to_string(&key_file)?;
167
168    let mut key_count = 0;
169    let mut key_names = Vec::new();
170
171    for line in content.lines() {
172        let line = line.trim();
173        if line.is_empty() || line.starts_with('#') {
174            continue;
175        }
176
177        let parts: Vec<&str> = line.split_whitespace().collect();
178        if parts.len() >= 2
179            && parts[0].len() == 16
180            && parts[0].chars().all(|c| c.is_ascii_hexdigit())
181            && parts[1].len() == 32
182            && parts[1].chars().all(|c| c.is_ascii_hexdigit())
183        {
184            key_count += 1;
185            if key_count <= 5 {
186                key_names.push(parts[0].to_string());
187            }
188        }
189    }
190
191    let metadata = fs::metadata(&key_file)?;
192    let file_size = metadata.len();
193    let modified = metadata
194        .modified()
195        .ok()
196        .and_then(|m| std::time::SystemTime::now().duration_since(m).ok())
197        .map(|d| format!("{}h ago", d.as_secs() / 3600))
198        .unwrap_or_else(|| "unknown".to_string());
199
200    match format {
201        OutputFormat::Json | OutputFormat::JsonPretty => {
202            let status = serde_json::json!({
203                "location": key_file.display().to_string(),
204                "total_keys": key_count,
205                "file_size_bytes": file_size,
206                "file_size_kb": file_size / 1024,
207                "last_updated": modified,
208                "sample_keys": key_names,
209                "status": "loaded"
210            });
211            let output = if matches!(format, OutputFormat::JsonPretty) {
212                serde_json::to_string_pretty(&status)?
213            } else {
214                serde_json::to_string(&status)?
215            };
216            println!("{output}");
217        }
218        _ => {
219            info!("📊 Key Database Status");
220            info!("  Location: {}", key_file.display());
221            info!("  Total keys: {}", key_count);
222            info!("  File size: {} KB", file_size / 1024);
223            info!("  Last updated: {}", modified);
224
225            if !key_names.is_empty() {
226                info!("  Sample keys:");
227                for name in key_names {
228                    info!("    - {}", name);
229                }
230                if key_count > 5 {
231                    info!("    ... and {} more", key_count - 5);
232                }
233            }
234        }
235    }
236
237    Ok(())
238}