fastskill_core/http/handlers/
registry_publish.rs1use 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#[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#[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
45pub async fn publish_package(
47 State(state): State<AppState>,
48 mut multipart: Multipart,
49) -> HttpResult<Json<ApiResponse<PublishResponse>>> {
50 let uploaded_by = Some("anonymous".to_string());
52 let user_scope = "anonymous".to_string();
53
54 let staging_dir = state
56 .service
57 .config()
58 .staging_dir
59 .clone()
60 .unwrap_or_else(|| std::path::PathBuf::from(".staging"));
61
62 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 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 let (id, version) = extract_skill_metadata_from_zip(&package_data)?;
120
121 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 let skill_id = format!("{}/{}", user_scope, id);
132
133 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 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 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 HttpError::InternalServerError("Failed to store package".to_string())
173 })?;
174
175 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
188pub async fn get_publish_status(
190 Path(job_id): Path<String>,
191 State(state): State<AppState>,
192) -> HttpResult<Json<ApiResponse<PublishStatusResponse>>> {
193 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 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 let config = state.service.config();
210 let blob_storage_configured = config.registry_blob_storage.is_some();
211
212 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 let blob_storage_url = if published_to_blob_storage == Some(true) {
224 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 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 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
299fn 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 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 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 #[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 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 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 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 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}