fastskill_core/http/handlers/
skill_storage.rs1use crate::core::service::FastSkillService;
4use crate::security::validate_path_component;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::fs;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillMetadata {
16 pub id: String,
17 pub name: String,
18 pub description: String,
19 pub directory: String,
20 pub created_at: DateTime<Utc>,
21 pub updated_at: DateTime<Utc>,
22 pub latest_version: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SkillVersion {
28 pub id: String,
29 pub skill_id: String,
30 pub version: String, pub created_at: DateTime<Utc>,
32 pub name: String,
33 pub description: String,
34 pub directory: String,
35}
36
37pub struct SkillStorage {
39 #[allow(dead_code)] service: Arc<FastSkillService>,
41 skills_dir: PathBuf,
42}
43
44impl SkillStorage {
45 pub fn new(service: Arc<FastSkillService>, skills_dir: PathBuf) -> Self {
47 let canonical_skills_dir = Self::canonicalize_path(skills_dir);
48 Self {
49 service,
50 skills_dir: canonical_skills_dir,
51 }
52 }
53
54 fn canonicalize_path(path: PathBuf) -> PathBuf {
56 path.canonicalize().unwrap_or(path)
57 }
58
59 pub fn generate_skill_id() -> String {
61 format!("skill_{}", Uuid::new_v4().simple())
62 }
63
64 pub fn generate_version_id() -> String {
66 chrono::Utc::now().timestamp_millis().to_string()
67 }
68
69 pub async fn store_skill_version(
71 &self,
72 skill_id: &str,
73 files: HashMap<String, Vec<u8>>,
74 ) -> Result<SkillVersion, crate::http::errors::HttpError> {
75 let skill_md_content = files
77 .get("SKILL.md")
78 .or_else(|| {
79 files
81 .keys()
82 .find(|k| k.ends_with("SKILL.md"))
83 .and_then(|k| files.get(k))
84 })
85 .ok_or_else(|| {
86 crate::http::errors::HttpError::BadRequest(
87 "SKILL.md file not found in upload".to_string(),
88 )
89 })?;
90
91 let metadata = self.parse_skill_metadata(skill_md_content)?;
93
94 let directory_raw = self.extract_directory_name(&files)?;
96
97 let directory = validate_path_component(&directory_raw).map_err(|e| {
99 crate::http::errors::HttpError::BadRequest(format!("Invalid directory name: {}", e))
100 })?;
101
102 let version_id = Self::generate_version_id();
104 let version = SkillVersion {
105 id: format!("skillver_{}", Uuid::new_v4().simple()),
106 skill_id: skill_id.to_string(),
107 version: version_id.clone(),
108 created_at: Utc::now(),
109 name: metadata.name,
110 description: metadata
111 .description
112 .unwrap_or_else(|| "No description provided".to_string()),
113 directory: directory.clone(),
114 };
115
116 let skill_path = self.skills_dir.join(&directory);
118 fs::create_dir_all(&skill_path).await.map_err(|e| {
119 crate::http::errors::HttpError::InternalServerError(format!(
120 "Failed to create skill directory: {}",
121 e
122 ))
123 })?;
124
125 for (filename, content) in files {
126 let filename_safe = validate_path_component(&filename).map_err(|e| {
128 crate::http::errors::HttpError::BadRequest(format!("Invalid filename: {}", e))
129 })?;
130
131 let file_path = skill_path.join(&filename_safe);
132
133 let canonical_skill_path = skill_path.canonicalize().map_err(|e| {
135 crate::http::errors::HttpError::InternalServerError(format!(
136 "Failed to resolve skill path: {}",
137 e
138 ))
139 })?;
140
141 if let Some(parent) = file_path.parent() {
143 fs::create_dir_all(parent).await.map_err(|e| {
144 crate::http::errors::HttpError::InternalServerError(format!(
145 "Failed to create parent directory: {}",
146 e
147 ))
148 })?;
149 }
150 if file_path.starts_with(&canonical_skill_path) {
152 fs::write(file_path, content).await.map_err(|e| {
153 crate::http::errors::HttpError::InternalServerError(format!(
154 "Failed to write file: {}",
155 e
156 ))
157 })?;
158 } else {
159 return Err(crate::http::errors::HttpError::BadRequest(format!(
160 "File path escapes skill directory: {}",
161 filename_safe
162 )));
163 }
164 }
165
166 self.update_skill_metadata(skill_id, &version).await?;
168
169 Ok(version)
170 }
171
172 fn parse_skill_metadata(
174 &self,
175 content: &[u8],
176 ) -> Result<SkillFrontmatter, crate::http::errors::HttpError> {
177 let content_str = String::from_utf8(content.to_vec()).map_err(|e| {
178 crate::http::errors::HttpError::BadRequest(format!("Invalid UTF-8 content: {}", e))
179 })?;
180 let lines: Vec<&str> = content_str.lines().collect();
181
182 let mut in_frontmatter = false;
184 let mut frontmatter_lines = Vec::new();
185
186 for line in lines {
187 if line.trim() == "---" {
188 if in_frontmatter {
189 break; } else {
191 in_frontmatter = true;
192 continue;
193 }
194 }
195
196 if in_frontmatter {
197 frontmatter_lines.push(line);
198 }
199 }
200
201 if frontmatter_lines.is_empty() {
202 return Err(crate::http::errors::HttpError::BadRequest(
203 "No YAML frontmatter found in SKILL.md".to_string(),
204 ));
205 }
206
207 let yaml_content = frontmatter_lines.join("\n");
208 let frontmatter: SkillFrontmatter = serde_yaml::from_str(&yaml_content).map_err(|e| {
209 crate::http::errors::HttpError::BadRequest(format!("Invalid YAML frontmatter: {}", e))
210 })?;
211
212 Ok(frontmatter)
213 }
214
215 fn extract_directory_name(
217 &self,
218 files: &HashMap<String, Vec<u8>>,
219 ) -> Result<String, crate::http::errors::HttpError> {
220 let mut directories = Vec::new();
222
223 for filename in files.keys() {
224 if let Some(dir) = Path::new(filename).parent() {
225 if let Some(dir_str) = dir.to_str() {
226 directories.push(dir_str.to_string());
227 }
228 }
229 }
230
231 if directories.is_empty() {
233 return Err(crate::http::errors::HttpError::BadRequest(
234 "No directory structure found in uploaded files".to_string(),
235 ));
236 }
237
238 let directory = directories.into_iter().next().ok_or_else(|| {
241 crate::http::errors::HttpError::InternalServerError(
242 "Unexpected empty directories list".to_string(),
243 )
244 })?;
245 Ok(directory)
246 }
247
248 async fn update_skill_metadata(
250 &self,
251 skill_id: &str,
252 version: &SkillVersion,
253 ) -> Result<(), crate::http::errors::HttpError> {
254 let metadata_path = self.skills_dir.join("metadata.json");
255
256 let mut metadata: HashMap<String, SkillMetadata> = if metadata_path.exists() {
258 let content = fs::read_to_string(&metadata_path).await.map_err(|e| {
259 crate::http::errors::HttpError::InternalServerError(format!(
260 "Failed to read metadata: {}",
261 e
262 ))
263 })?;
264 serde_json::from_str(&content).map_err(|e| {
265 crate::http::errors::HttpError::InternalServerError(format!(
266 "Failed to parse metadata: {}",
267 e
268 ))
269 })?
270 } else {
271 HashMap::new()
272 };
273
274 let skill_meta = metadata
276 .entry(skill_id.to_string())
277 .or_insert(SkillMetadata {
278 id: skill_id.to_string(),
279 name: version.name.clone(),
280 description: version.description.clone(),
281 directory: version.directory.clone(),
282 created_at: version.created_at,
283 updated_at: version.created_at,
284 latest_version: version.version.clone(),
285 });
286
287 skill_meta.updated_at = version.created_at;
288 skill_meta.latest_version = version.version.clone();
289
290 let content = serde_json::to_string_pretty(&metadata).map_err(|e| {
292 crate::http::errors::HttpError::InternalServerError(format!(
293 "Failed to serialize metadata: {}",
294 e
295 ))
296 })?;
297 fs::write(metadata_path, content).await.map_err(|e| {
298 crate::http::errors::HttpError::InternalServerError(format!(
299 "Failed to write metadata: {}",
300 e
301 ))
302 })?;
303
304 Ok(())
305 }
306
307 pub async fn get_skill(
309 &self,
310 skill_id: &str,
311 ) -> Result<Option<SkillMetadata>, crate::http::errors::HttpError> {
312 let metadata_path = self.skills_dir.join("metadata.json");
313
314 if !metadata_path.exists() {
315 return Ok(None);
316 }
317
318 let content = fs::read_to_string(metadata_path).await.map_err(|e| {
319 crate::http::errors::HttpError::InternalServerError(format!(
320 "Failed to read metadata: {}",
321 e
322 ))
323 })?;
324 let metadata: HashMap<String, SkillMetadata> =
325 serde_json::from_str(&content).map_err(|e| {
326 crate::http::errors::HttpError::InternalServerError(format!(
327 "Failed to parse metadata: {}",
328 e
329 ))
330 })?;
331
332 Ok(metadata.get(skill_id).cloned())
333 }
334
335 pub async fn list_skills(&self) -> Result<Vec<SkillMetadata>, crate::http::errors::HttpError> {
337 let metadata_path = self.skills_dir.join("metadata.json");
338
339 if !metadata_path.exists() {
340 return Ok(Vec::new());
341 }
342
343 let content = fs::read_to_string(metadata_path).await.map_err(|e| {
344 crate::http::errors::HttpError::InternalServerError(format!(
345 "Failed to read metadata: {}",
346 e
347 ))
348 })?;
349 let metadata: HashMap<String, SkillMetadata> =
350 serde_json::from_str(&content).map_err(|e| {
351 crate::http::errors::HttpError::InternalServerError(format!(
352 "Failed to parse metadata: {}",
353 e
354 ))
355 })?;
356
357 Ok(metadata.values().cloned().collect())
358 }
359
360 pub async fn delete_skill(&self, skill_id: &str) -> Result<(), crate::http::errors::HttpError> {
362 let skill_meta = match self.get_skill(skill_id).await? {
364 Some(meta) => meta,
365 None => return Ok(()), };
367
368 let directory_safe = validate_path_component(&skill_meta.directory).map_err(|e| {
370 crate::http::errors::HttpError::InternalServerError(format!(
371 "Invalid skill directory: {}",
372 e
373 ))
374 })?;
375
376 let skill_path = self.skills_dir.join(&directory_safe);
378
379 let canonical_skills_dir = self.skills_dir.canonicalize().map_err(|e| {
381 crate::http::errors::HttpError::InternalServerError(format!(
382 "Failed to resolve skills directory: {}",
383 e
384 ))
385 })?;
386
387 if skill_path.exists() {
388 let canonical_skill_path = skill_path.canonicalize().map_err(|e| {
389 crate::http::errors::HttpError::InternalServerError(format!(
390 "Failed to resolve skill path: {}",
391 e
392 ))
393 })?;
394
395 if !canonical_skill_path.starts_with(&canonical_skills_dir) {
396 return Err(crate::http::errors::HttpError::BadRequest(
397 "Skill path escapes skills directory".to_string(),
398 ));
399 }
400
401 fs::remove_dir_all(canonical_skill_path)
402 .await
403 .map_err(|e| {
404 crate::http::errors::HttpError::InternalServerError(format!(
405 "Failed to remove skill directory: {}",
406 e
407 ))
408 })?;
409 }
410
411 let metadata_path = self.skills_dir.join("metadata.json");
413 if metadata_path.exists() {
414 let content = fs::read_to_string(&metadata_path).await.map_err(|e| {
415 crate::http::errors::HttpError::InternalServerError(format!(
416 "Failed to read metadata: {}",
417 e
418 ))
419 })?;
420 let mut metadata: HashMap<String, SkillMetadata> = serde_json::from_str(&content)
421 .map_err(|e| {
422 crate::http::errors::HttpError::InternalServerError(format!(
423 "Failed to parse metadata: {}",
424 e
425 ))
426 })?;
427 metadata.remove(skill_id);
428
429 let updated_content = serde_json::to_string_pretty(&metadata).map_err(|e| {
430 crate::http::errors::HttpError::InternalServerError(format!(
431 "Failed to serialize metadata: {}",
432 e
433 ))
434 })?;
435 fs::write(metadata_path, updated_content)
436 .await
437 .map_err(|e| {
438 crate::http::errors::HttpError::InternalServerError(format!(
439 "Failed to write metadata: {}",
440 e
441 ))
442 })?;
443 }
444
445 Ok(())
446 }
447}
448
449#[derive(Debug, Deserialize)]
451struct SkillFrontmatter {
452 name: String,
453 description: Option<String>,
454}
455
456impl Default for SkillFrontmatter {
457 fn default() -> Self {
458 Self {
459 name: "Unnamed Skill".to_string(),
460 description: Some("No description provided".to_string()),
461 }
462 }
463}