Skip to main content

fastskill_core/http/handlers/
registry_publish.rs

1//! Registry publish endpoint handlers
2
3use crate::core::metadata::parse_yaml_frontmatter;
4use crate::core::registry::staging::{StagingManager, StagingStatus};
5use crate::http::errors::{HttpError, HttpResult};
6use crate::http::handlers::AppState;
7use crate::http::models::*;
8use crate::security::validate_path_component;
9use axum::{
10    extract::{Multipart, Path, State},
11    Json,
12};
13use serde::Serialize;
14use std::io::Read;
15use zip::ZipArchive;
16
17/// Publish package response
18#[derive(Debug, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct PublishResponse {
21    pub job_id: String,
22    pub status: String,
23    pub skill_id: String,
24    pub version: String,
25    pub message: String,
26}
27
28/// Publish status response
29#[derive(Debug, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct PublishStatusResponse {
32    pub job_id: String,
33    pub status: String,
34    pub skill_id: String,
35    pub version: String,
36    pub checksum: String,
37    pub uploaded_at: String,
38    pub uploaded_by: Option<String>,
39    pub validation_errors: Vec<String>,
40    pub message: Option<String>,
41    pub published_to_blob_storage: Option<bool>,
42    pub blob_storage_url: Option<String>,
43}
44
45/// POST /api/registry/publish - Publish a skill package
46pub async fn publish_package(
47    State(state): State<AppState>,
48    mut multipart: Multipart,
49) -> HttpResult<Json<ApiResponse<PublishResponse>>> {
50    // Set fixed identity values for anonymous publishing
51    let uploaded_by = Some("anonymous".to_string());
52    let user_scope = "anonymous".to_string();
53
54    // Get staging manager
55    let staging_dir = state
56        .service
57        .config()
58        .staging_dir
59        .clone()
60        .unwrap_or_else(|| std::path::PathBuf::from(".staging"));
61
62    // Canonicalize staging_dir if it exists to prevent path traversal
63    let safe_staging_dir = if staging_dir.exists() {
64        staging_dir.canonicalize().map_err(|e| {
65            HttpError::InternalServerError(format!("Failed to resolve staging directory: {}", e))
66        })?
67    } else {
68        staging_dir.clone()
69    };
70
71    tracing::info!("Using staging directory: {}", safe_staging_dir.display());
72    tracing::info!("Staging directory exists: {}", safe_staging_dir.exists());
73
74    if !safe_staging_dir.exists() {
75        tracing::info!("Creating staging directory: {}", safe_staging_dir.display());
76        std::fs::create_dir_all(&safe_staging_dir).map_err(|e| {
77            tracing::error!(
78                "Failed to create staging directory {}: {}",
79                safe_staging_dir.display(),
80                e
81            );
82            HttpError::InternalServerError("Failed to create staging directory".to_string())
83        })?;
84    }
85
86    let staging_manager = StagingManager::new(safe_staging_dir.clone());
87    tracing::info!(
88        "Initializing staging manager with directory: {}",
89        safe_staging_dir.display()
90    );
91    staging_manager.initialize().map_err(|e| {
92        tracing::error!("Failed to initialize staging manager: {}", e);
93        HttpError::InternalServerError("Failed to initialize staging".to_string())
94    })?;
95
96    // Extract file from multipart
97    let mut package_data: Option<Vec<u8>> = None;
98
99    while let Some(field) = multipart
100        .next_field()
101        .await
102        .map_err(|e| HttpError::BadRequest(format!("Failed to read multipart field: {}", e)))?
103    {
104        let field_name = field.name().unwrap_or("");
105
106        if field_name == "file" || field_name == "package" {
107            let data = field
108                .bytes()
109                .await
110                .map_err(|e| HttpError::BadRequest(format!("Failed to read file data: {}", e)))?;
111            package_data = Some(data.to_vec());
112        }
113    }
114
115    let package_data = package_data
116        .ok_or_else(|| HttpError::BadRequest("No file provided in multipart form".to_string()))?;
117
118    // Extract id and version from ZIP (id comes from skill-project.toml)
119    let (id, version) = extract_skill_metadata_from_zip(&package_data)?;
120
121    // Validate id and version to prevent path traversal
122    validate_path_component(&id)
123        .map_err(|e| HttpError::BadRequest(format!("Invalid skill ID: {}", e)))?;
124    validate_path_component(&version)
125        .map_err(|e| HttpError::BadRequest(format!("Invalid version: {}", e)))?;
126    validate_path_component(&user_scope)
127        .map_err(|e| HttpError::BadRequest(format!("Invalid user scope: {}", e)))?;
128
129    // Combine user scope with id: scope/id (for response)
130    // id from skill-project.toml should not contain slashes
131    let skill_id = format!("{}/{}", user_scope, id);
132
133    // Store in staging (pass scope and id separately)
134    tracing::info!(
135        "Storing package: scope={}, id={}, version={}, package_size={} bytes",
136        user_scope,
137        id,
138        version,
139        package_data.len()
140    );
141
142    // Calculate the staging path that will be created
143    let staging_path = staging_manager.get_staging_path(&user_scope, &id, &version)?;
144    tracing::info!("Calculated staging path: {}", staging_path.display());
145
146    let (_, job_id) = staging_manager
147        .store_package(
148            &user_scope,
149            &id,
150            &version,
151            &package_data,
152            uploaded_by.as_deref(),
153        )
154        .await
155        .map_err(|e| {
156            // Log detailed error information on server side for debugging
157            tracing::error!("Failed to store package {} v{}: {}", skill_id, version, e);
158            tracing::error!("Staging directory: {}", safe_staging_dir.display());
159            tracing::error!("Staging directory exists: {}", safe_staging_dir.exists());
160            tracing::error!("Calculated staging path: {}", staging_path.display());
161            tracing::error!(
162                "Staging path parent exists: {}",
163                staging_path.parent().is_some_and(|p| p.exists())
164            );
165            if let Some(parent) = staging_path.parent() {
166                tracing::error!("Staging path parent: {}", parent.display());
167                if let Err(check_err) = std::fs::read_dir(parent) {
168                    tracing::error!("Cannot read staging path parent: {}", check_err);
169                }
170            }
171            // Return generic error message to client for security
172            HttpError::InternalServerError("Failed to store package".to_string())
173        })?;
174
175    // Validation worker will automatically pick up and process this package
176
177    let response = PublishResponse {
178        job_id,
179        status: "pending".to_string(),
180        skill_id,
181        version,
182        message: "Package queued for validation".to_string(),
183    };
184
185    Ok(Json(ApiResponse::success(response)))
186}
187
188/// GET /api/registry/publish/status/:job_id - Get publish job status
189pub async fn get_publish_status(
190    Path(job_id): Path<String>,
191    State(state): State<AppState>,
192) -> HttpResult<Json<ApiResponse<PublishStatusResponse>>> {
193    // Get staging manager
194    let staging_dir = state
195        .service
196        .config()
197        .staging_dir
198        .clone()
199        .unwrap_or_else(|| std::path::PathBuf::from(".staging"));
200    let staging_manager = StagingManager::new(staging_dir);
201
202    // Load metadata
203    let metadata = staging_manager
204        .load_metadata(&job_id)
205        .map_err(|e| HttpError::InternalServerError(format!("Failed to load metadata: {}", e)))?
206        .ok_or_else(|| HttpError::NotFound(format!("Job {} not found", job_id)))?;
207
208    // Check server configuration to determine publishing status
209    let config = state.service.config();
210    let blob_storage_configured = config.registry_blob_storage.is_some();
211
212    // Determine if package was actually published (only if accepted and configs are present)
213    let published_to_blob_storage =
214        if metadata.status == StagingStatus::Accepted && blob_storage_configured {
215            Some(true)
216        } else if metadata.status == StagingStatus::Accepted {
217            Some(false)
218        } else {
219            None
220        };
221
222    // Try to get blob storage URL if published
223    let blob_storage_url = if published_to_blob_storage == Some(true) {
224        // Try to construct URL from config
225        if let Some(ref blob_config) = config.registry_blob_storage {
226            let package_path = staging_manager
227                .get_package_path(&job_id)
228                .map_err(|e| {
229                    HttpError::InternalServerError(format!("Failed to get package path: {}", e))
230                })?
231                .ok_or_else(|| {
232                    HttpError::InternalServerError(format!("Package not found for job {}", job_id))
233                })?;
234
235            let package_filename = package_path
236                .file_name()
237                .and_then(|n| n.to_str())
238                .unwrap_or("unknown");
239
240            // Extract scope from the user who uploaded the package
241            // Scope is the part before '/' in uploaded_by, or the entire uploaded_by if no '/'
242            let scope = metadata
243                .uploaded_by
244                .as_ref()
245                .map(|u| u.split('/').next().unwrap_or(u).to_string())
246                .unwrap_or_else(|| "unknown".to_string());
247
248            let storage_path = format!("skills/{}/{}", scope, package_filename);
249
250            if let Some(base_url) = &blob_config.base_url {
251                Some(format!(
252                    "{}/{}",
253                    base_url.trim_end_matches('/'),
254                    storage_path
255                ))
256            } else {
257                config.registry_blob_base_url.as_ref().map(|blob_base_url| {
258                    format!("{}/{}", blob_base_url.trim_end_matches('/'), storage_path)
259                })
260            }
261        } else {
262            None
263        }
264    } else {
265        None
266    };
267
268    // Build descriptive message
269    let message = match metadata.status {
270        StagingStatus::Pending => Some("Package is pending validation".to_string()),
271        StagingStatus::Validating => Some("Package is being validated".to_string()),
272        StagingStatus::Accepted => {
273            if blob_storage_configured {
274                Some("Package has been accepted (published to blob storage)".to_string())
275            } else {
276                Some("Package has been accepted (staging only)".to_string())
277            }
278        }
279        StagingStatus::Rejected => Some("Package was rejected during validation".to_string()),
280    };
281
282    let response = PublishStatusResponse {
283        job_id: metadata.job_id.clone(),
284        status: metadata.status.as_str().to_string(),
285        skill_id: metadata.skill_id,
286        version: metadata.version,
287        checksum: metadata.checksum,
288        uploaded_at: metadata.uploaded_at.to_rfc3339(),
289        uploaded_by: metadata.uploaded_by,
290        validation_errors: metadata.validation_errors,
291        message,
292        published_to_blob_storage,
293        blob_storage_url,
294    };
295
296    Ok(Json(ApiResponse::success(response)))
297}
298
299/// Extract skill_id and version from ZIP package
300/// id is mandatory from skill-project.toml
301/// version priority: skill-project.toml > SKILL.md frontmatter > default "1.0.0"
302fn extract_skill_metadata_from_zip(zip_data: &[u8]) -> HttpResult<(String, String)> {
303    use crate::core::manifest::MetadataSection;
304    use std::io::Cursor;
305
306    let cursor = Cursor::new(zip_data);
307    let mut archive = ZipArchive::new(cursor)
308        .map_err(|e| HttpError::BadRequest(format!("Invalid ZIP file: {}", e)))?;
309
310    // Find and read SKILL.md and skill-project.toml
311    let mut skill_content = String::new();
312    let mut skill_project_content: Option<String> = None;
313
314    for i in 0..archive.len() {
315        let file = archive
316            .by_index(i)
317            .map_err(|e| HttpError::BadRequest(format!("Failed to read ZIP entry: {}", e)))?;
318
319        let file_name = file.name();
320
321        if file_name.ends_with("SKILL.md") {
322            let mut reader = std::io::BufReader::new(file);
323            reader
324                .read_to_string(&mut skill_content)
325                .map_err(|e| HttpError::BadRequest(format!("Failed to read SKILL.md: {}", e)))?;
326        } else if file_name.ends_with("skill-project.toml") {
327            let mut reader = std::io::BufReader::new(file);
328            let mut content = String::new();
329            reader.read_to_string(&mut content).map_err(|e| {
330                HttpError::BadRequest(format!("Failed to read skill-project.toml: {}", e))
331            })?;
332            skill_project_content = Some(content);
333        }
334    }
335
336    // skill-project.toml is mandatory
337    let skill_project_str = skill_project_content.ok_or_else(|| {
338        HttpError::BadRequest("skill-project.toml is required but not found in package".to_string())
339    })?;
340
341    // Parse skill-project.toml to extract id (mandatory) and version
342    #[derive(serde::Deserialize)]
343    struct SkillProjectToml {
344        #[serde(default)]
345        metadata: Option<MetadataSection>,
346    }
347
348    let skill_project: SkillProjectToml = toml::from_str(&skill_project_str)
349        .map_err(|e| HttpError::BadRequest(format!("Failed to parse skill-project.toml: {}", e)))?;
350
351    let metadata = skill_project.metadata.ok_or_else(|| {
352        HttpError::BadRequest("skill-project.toml must have a [metadata] section".to_string())
353    })?;
354
355    // Extract id (mandatory)
356    let skill_id = metadata.id.ok_or_else(|| {
357        HttpError::BadRequest(
358            "skill-project.toml [metadata] section must have a non-empty 'id' field".to_string(),
359        )
360    })?;
361
362    // Extract version: priority skill-project.toml > SKILL.md frontmatter > default
363    let version = if let Some(ref v) = metadata.version {
364        if !v.is_empty() {
365            v.clone()
366        } else if !skill_content.is_empty() {
367            // Try to get version from SKILL.md frontmatter as fallback
368            let frontmatter = parse_yaml_frontmatter(&skill_content).ok();
369            if let Some(ref f) = frontmatter {
370                if let Some(ref v) = f.version {
371                    if !v.is_empty() {
372                        v.clone()
373                    } else {
374                        "1.0.0".to_string()
375                    }
376                } else {
377                    "1.0.0".to_string()
378                }
379            } else {
380                "1.0.0".to_string()
381            }
382        } else {
383            "1.0.0".to_string()
384        }
385    } else if !skill_content.is_empty() {
386        // Try to get version from SKILL.md frontmatter as fallback
387        let frontmatter = parse_yaml_frontmatter(&skill_content).ok();
388        if let Some(ref f) = frontmatter {
389            if let Some(ref v) = f.version {
390                if !v.is_empty() {
391                    v.clone()
392                } else {
393                    "1.0.0".to_string()
394                }
395            } else {
396                "1.0.0".to_string()
397            }
398        } else {
399            "1.0.0".to_string()
400        }
401    } else {
402        "1.0.0".to_string()
403    };
404
405    Ok((skill_id, version))
406}