use crate::core::metadata::parse_yaml_frontmatter;
use crate::core::registry::staging::{StagingManager, StagingStatus};
use crate::http::errors::{HttpError, HttpResult};
use crate::http::handlers::AppState;
use crate::http::models::*;
use crate::security::validate_path_component;
use axum::{
extract::{Multipart, Path, State},
Json,
};
use serde::Serialize;
use std::io::Read;
use zip::ZipArchive;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishResponse {
pub job_id: String,
pub status: String,
pub skill_id: String,
pub version: String,
pub message: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishStatusResponse {
pub job_id: String,
pub status: String,
pub skill_id: String,
pub version: String,
pub checksum: String,
pub uploaded_at: String,
pub uploaded_by: Option<String>,
pub validation_errors: Vec<String>,
pub message: Option<String>,
pub published_to_blob_storage: Option<bool>,
pub blob_storage_url: Option<String>,
}
pub async fn publish_package(
State(state): State<AppState>,
mut multipart: Multipart,
) -> HttpResult<Json<ApiResponse<PublishResponse>>> {
let uploaded_by = Some("anonymous".to_string());
let user_scope = "anonymous".to_string();
let staging_dir = state
.service
.config()
.staging_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(".staging"));
let safe_staging_dir = if staging_dir.exists() {
staging_dir.canonicalize().map_err(|e| {
HttpError::InternalServerError(format!("Failed to resolve staging directory: {}", e))
})?
} else {
staging_dir.clone()
};
tracing::info!("Using staging directory: {}", safe_staging_dir.display());
tracing::info!("Staging directory exists: {}", safe_staging_dir.exists());
if !safe_staging_dir.exists() {
tracing::info!("Creating staging directory: {}", safe_staging_dir.display());
std::fs::create_dir_all(&safe_staging_dir).map_err(|e| {
tracing::error!(
"Failed to create staging directory {}: {}",
safe_staging_dir.display(),
e
);
HttpError::InternalServerError("Failed to create staging directory".to_string())
})?;
}
let staging_manager = StagingManager::new(safe_staging_dir.clone());
tracing::info!(
"Initializing staging manager with directory: {}",
safe_staging_dir.display()
);
staging_manager.initialize().map_err(|e| {
tracing::error!("Failed to initialize staging manager: {}", e);
HttpError::InternalServerError("Failed to initialize staging".to_string())
})?;
let mut package_data: Option<Vec<u8>> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| HttpError::BadRequest(format!("Failed to read multipart field: {}", e)))?
{
let field_name = field.name().unwrap_or("");
if field_name == "file" || field_name == "package" {
let data = field
.bytes()
.await
.map_err(|e| HttpError::BadRequest(format!("Failed to read file data: {}", e)))?;
package_data = Some(data.to_vec());
}
}
let package_data = package_data
.ok_or_else(|| HttpError::BadRequest("No file provided in multipart form".to_string()))?;
let (id, version) = extract_skill_metadata_from_zip(&package_data)?;
validate_path_component(&id)
.map_err(|e| HttpError::BadRequest(format!("Invalid skill ID: {}", e)))?;
validate_path_component(&version)
.map_err(|e| HttpError::BadRequest(format!("Invalid version: {}", e)))?;
validate_path_component(&user_scope)
.map_err(|e| HttpError::BadRequest(format!("Invalid user scope: {}", e)))?;
let skill_id = format!("{}/{}", user_scope, id);
tracing::info!(
"Storing package: scope={}, id={}, version={}, package_size={} bytes",
user_scope,
id,
version,
package_data.len()
);
let staging_path = staging_manager.get_staging_path(&user_scope, &id, &version)?;
tracing::info!("Calculated staging path: {}", staging_path.display());
let (_, job_id) = staging_manager
.store_package(
&user_scope,
&id,
&version,
&package_data,
uploaded_by.as_deref(),
)
.await
.map_err(|e| {
tracing::error!("Failed to store package {} v{}: {}", skill_id, version, e);
tracing::error!("Staging directory: {}", safe_staging_dir.display());
tracing::error!("Staging directory exists: {}", safe_staging_dir.exists());
tracing::error!("Calculated staging path: {}", staging_path.display());
tracing::error!(
"Staging path parent exists: {}",
staging_path.parent().is_some_and(|p| p.exists())
);
if let Some(parent) = staging_path.parent() {
tracing::error!("Staging path parent: {}", parent.display());
if let Err(check_err) = std::fs::read_dir(parent) {
tracing::error!("Cannot read staging path parent: {}", check_err);
}
}
HttpError::InternalServerError("Failed to store package".to_string())
})?;
let response = PublishResponse {
job_id,
status: "pending".to_string(),
skill_id,
version,
message: "Package queued for validation".to_string(),
};
Ok(Json(ApiResponse::success(response)))
}
pub async fn get_publish_status(
Path(job_id): Path<String>,
State(state): State<AppState>,
) -> HttpResult<Json<ApiResponse<PublishStatusResponse>>> {
let staging_dir = state
.service
.config()
.staging_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(".staging"));
let staging_manager = StagingManager::new(staging_dir);
let metadata = staging_manager
.load_metadata(&job_id)
.map_err(|e| HttpError::InternalServerError(format!("Failed to load metadata: {}", e)))?
.ok_or_else(|| HttpError::NotFound(format!("Job {} not found", job_id)))?;
let config = state.service.config();
let blob_storage_configured = config.registry_blob_storage.is_some();
let published_to_blob_storage =
if metadata.status == StagingStatus::Accepted && blob_storage_configured {
Some(true)
} else if metadata.status == StagingStatus::Accepted {
Some(false)
} else {
None
};
let blob_storage_url = if published_to_blob_storage == Some(true) {
if let Some(ref blob_config) = config.registry_blob_storage {
let package_path = staging_manager
.get_package_path(&job_id)
.map_err(|e| {
HttpError::InternalServerError(format!("Failed to get package path: {}", e))
})?
.ok_or_else(|| {
HttpError::InternalServerError(format!("Package not found for job {}", job_id))
})?;
let package_filename = package_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let scope = metadata
.uploaded_by
.as_ref()
.map(|u| u.split('/').next().unwrap_or(u).to_string())
.unwrap_or_else(|| "unknown".to_string());
let storage_path = format!("skills/{}/{}", scope, package_filename);
if let Some(base_url) = &blob_config.base_url {
Some(format!(
"{}/{}",
base_url.trim_end_matches('/'),
storage_path
))
} else {
config.registry_blob_base_url.as_ref().map(|blob_base_url| {
format!("{}/{}", blob_base_url.trim_end_matches('/'), storage_path)
})
}
} else {
None
}
} else {
None
};
let message = match metadata.status {
StagingStatus::Pending => Some("Package is pending validation".to_string()),
StagingStatus::Validating => Some("Package is being validated".to_string()),
StagingStatus::Accepted => {
if blob_storage_configured {
Some("Package has been accepted (published to blob storage)".to_string())
} else {
Some("Package has been accepted (staging only)".to_string())
}
}
StagingStatus::Rejected => Some("Package was rejected during validation".to_string()),
};
let response = PublishStatusResponse {
job_id: metadata.job_id.clone(),
status: metadata.status.as_str().to_string(),
skill_id: metadata.skill_id,
version: metadata.version,
checksum: metadata.checksum,
uploaded_at: metadata.uploaded_at.to_rfc3339(),
uploaded_by: metadata.uploaded_by,
validation_errors: metadata.validation_errors,
message,
published_to_blob_storage,
blob_storage_url,
};
Ok(Json(ApiResponse::success(response)))
}
fn extract_skill_metadata_from_zip(zip_data: &[u8]) -> HttpResult<(String, String)> {
use crate::core::manifest::MetadataSection;
use std::io::Cursor;
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor)
.map_err(|e| HttpError::BadRequest(format!("Invalid ZIP file: {}", e)))?;
let mut skill_content = String::new();
let mut skill_project_content: Option<String> = None;
for i in 0..archive.len() {
let file = archive
.by_index(i)
.map_err(|e| HttpError::BadRequest(format!("Failed to read ZIP entry: {}", e)))?;
let file_name = file.name();
if file_name.ends_with("SKILL.md") {
let mut reader = std::io::BufReader::new(file);
reader
.read_to_string(&mut skill_content)
.map_err(|e| HttpError::BadRequest(format!("Failed to read SKILL.md: {}", e)))?;
} else if file_name.ends_with("skill-project.toml") {
let mut reader = std::io::BufReader::new(file);
let mut content = String::new();
reader.read_to_string(&mut content).map_err(|e| {
HttpError::BadRequest(format!("Failed to read skill-project.toml: {}", e))
})?;
skill_project_content = Some(content);
}
}
let skill_project_str = skill_project_content.ok_or_else(|| {
HttpError::BadRequest("skill-project.toml is required but not found in package".to_string())
})?;
#[derive(serde::Deserialize)]
struct SkillProjectToml {
#[serde(default)]
metadata: Option<MetadataSection>,
}
let skill_project: SkillProjectToml = toml::from_str(&skill_project_str)
.map_err(|e| HttpError::BadRequest(format!("Failed to parse skill-project.toml: {}", e)))?;
let metadata = skill_project.metadata.ok_or_else(|| {
HttpError::BadRequest("skill-project.toml must have a [metadata] section".to_string())
})?;
let skill_id = metadata.id.ok_or_else(|| {
HttpError::BadRequest(
"skill-project.toml [metadata] section must have a non-empty 'id' field".to_string(),
)
})?;
let version = if let Some(ref v) = metadata.version {
if !v.is_empty() {
v.clone()
} else if !skill_content.is_empty() {
let frontmatter = parse_yaml_frontmatter(&skill_content).ok();
if let Some(ref f) = frontmatter {
if let Some(ref v) = f.version {
if !v.is_empty() {
v.clone()
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
}
} else if !skill_content.is_empty() {
let frontmatter = parse_yaml_frontmatter(&skill_content).ok();
if let Some(ref f) = frontmatter {
if let Some(ref v) = f.version {
if !v.is_empty() {
v.clone()
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
}
} else {
"1.0.0".to_string()
};
Ok((skill_id, version))
}