use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser, OptionalAuthUser},
models::{AuditEventType, FeatureType, TemplateCategory, TemplateVersion, UsageCounter, User},
AppState,
};
pub async fn search_templates(
State(state): State<AppState>,
OptionalAuthUser(maybe_user_id): OptionalAuthUser,
headers: HeaderMap,
Json(query): Json<TemplateSearchQuery>,
) -> ApiResult<Json<TemplateSearchResults>> {
let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "template");
let pool = state.db.pool();
let org_id = if let Some(user_id) = maybe_user_id {
if let Ok(org_ctx) = resolve_org_context(&state, user_id, &headers, None).await {
Some(org_ctx.org_id)
} else {
None
}
} else {
None
};
let per_page = query.per_page.clamp(1, 100); let page = query.page;
let limit = per_page as i64;
let offset = (page * per_page) as i64;
let templates = state
.store
.search_templates(
query.query.as_deref(),
query.category.as_deref(),
&query.tags,
org_id,
limit,
offset,
)
.await?;
let total = state
.store
.count_search_templates(
query.query.as_deref(),
query.category.as_deref(),
&query.tags,
org_id,
)
.await? as usize;
let page_ids: Vec<uuid::Uuid> = templates.iter().map(|t| t.id).collect();
let star_counts = state.store.count_template_stars_batch(&page_ids).await?;
let mut entries = Vec::new();
for template in templates {
let _versions = TemplateVersion::get_by_template(pool, template.id)
.await
.map_err(ApiError::Database)?;
let author = state
.store
.find_user_by_id(template.author_id)
.await?
.unwrap_or_else(|| User::placeholder(template.author_id));
let stats = template.stats_json.clone();
let compatibility = template.compatibility_json.clone();
let category = template.category();
let template_name = template.name.clone();
let live_stars = star_counts.get(&template.id).copied().unwrap_or(0);
let mut stats_out =
serde_json::from_value::<TemplateStats>(stats).unwrap_or(TemplateStats {
downloads: 0,
stars: 0,
forks: 0,
rating: 0.0,
rating_count: 0,
});
stats_out.stars = live_stars as u64;
entries.push(TemplateRegistryEntry {
id: template.id.to_string(),
name: template.name,
description: template.description,
author: author.username,
author_email: Some(author.email),
version: template.version,
category,
tags: template.tags,
content: template.content_json,
readme: template.readme,
example_usage: template.example_usage,
requirements: template.requirements,
compatibility: serde_json::from_value(compatibility).unwrap_or_else(|e| {
tracing::warn!(
"Failed to parse compatibility JSON for template '{}': {}",
template_name,
e
);
CompatibilityInfo {
min_version: "0.1.0".to_string(),
max_version: None,
required_features: vec![],
protocols: vec![],
}
}),
stats: stats_out,
created_at: template.created_at.to_rfc3339(),
updated_at: template.updated_at.to_rfc3339(),
published: template.published,
});
}
metrics.record_search_success();
Ok(Json(TemplateSearchResults {
templates: entries,
total,
page,
per_page,
}))
}
pub async fn get_template(
State(state): State<AppState>,
Path((name, version)): Path<(String, String)>,
) -> ApiResult<Json<TemplateRegistryEntry>> {
let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "template");
let template = state
.store
.find_template_by_name_version(&name, &version)
.await?
.ok_or_else(|| ApiError::TemplateNotFound(format!("{}@{}", name, version)))?;
let author = state
.store
.find_user_by_id(template.author_id)
.await?
.unwrap_or_else(|| User::placeholder(template.author_id));
let stats = template.stats_json.clone();
let compatibility = template.compatibility_json.clone();
let category = template.category();
let template_name = template.name.clone();
let live_stars = state.store.count_template_stars(template.id).await?;
let mut stats_out = serde_json::from_value::<TemplateStats>(stats).unwrap_or(TemplateStats {
downloads: 0,
stars: 0,
forks: 0,
rating: 0.0,
rating_count: 0,
});
stats_out.stars = live_stars as u64;
metrics.record_download_success();
Ok(Json(TemplateRegistryEntry {
id: template.id.to_string(),
name: template.name,
description: template.description,
author: author.username,
author_email: Some(author.email),
version: template.version,
category,
tags: template.tags,
content: template.content_json,
readme: template.readme,
example_usage: template.example_usage,
requirements: template.requirements,
compatibility: serde_json::from_value(compatibility).unwrap_or_else(|e| {
tracing::warn!(
"Failed to parse compatibility JSON for template '{}': {}",
template_name,
e
);
CompatibilityInfo {
min_version: "0.1.0".to_string(),
max_version: None,
required_features: vec![],
protocols: vec![],
}
}),
stats: stats_out,
created_at: template.created_at.to_rfc3339(),
updated_at: template.updated_at.to_rfc3339(),
published: template.published,
}))
}
pub async fn publish_template(
State(state): State<AppState>,
AuthUser(author_id): AuthUser,
headers: HeaderMap,
Json(request): Json<PublishTemplateRequest>,
) -> ApiResult<Json<PublishTemplateResponse>> {
let metrics = crate::metrics::MarketplaceMetrics::start(state.metrics.clone(), "template");
let pool = state.db.pool();
let org_ctx = resolve_org_context(&state, author_id, &headers, None)
.await
.map_err(|_| ApiError::OrganizationNotFound)?;
let limits = &org_ctx.org.limits_json;
let max_templates = limits.get("max_templates_published").and_then(|v| v.as_i64()).unwrap_or(3);
if max_templates >= 0 {
let existing = state.store.list_templates_by_org(org_ctx.org_id).await?;
if existing.len() as i64 >= max_templates {
return Err(ApiError::InvalidRequest(format!(
"Template limit exceeded. Your plan allows {} templates. Upgrade to publish more.",
max_templates
)));
}
}
let storage_limit_gb = limits.get("storage_gb").and_then(|v| v.as_i64()).unwrap_or(1);
let storage_limit_bytes = storage_limit_gb * 1_000_000_000;
let usage = UsageCounter::get_or_create_current(pool, org_ctx.org_id)
.await
.map_err(ApiError::Database)?;
let new_storage = usage.storage_bytes + request.file_size;
if new_storage > storage_limit_bytes {
return Err(ApiError::InvalidRequest(format!(
"Storage limit exceeded. Your plan allows {} GB.",
storage_limit_gb
)));
}
crate::validation::validate_name(&request.name)?;
crate::validation::validate_name(&request.slug)?;
crate::validation::validate_version(&request.version)?;
crate::validation::validate_checksum(&request.checksum)?;
crate::validation::validate_base64(&request.package)?;
let package_data = base64::engine::general_purpose::STANDARD
.decode(&request.package)
.map_err(|e| ApiError::InvalidRequest(format!("Invalid base64: {}", e)))?;
crate::validation::validate_package_file(
&package_data,
request.file_size as u64,
crate::validation::MAX_TEMPLATE_SIZE,
)?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&package_data);
let calculated_checksum = hex::encode(hasher.finalize());
if calculated_checksum != request.checksum {
return Err(ApiError::InvalidRequest("Checksum mismatch".to_string()));
}
let download_url = state
.storage
.upload_template(&request.name, &request.version, package_data)
.await
.map_err(|e| ApiError::Storage(e.to_string()))?;
let template = if let Some(existing) = state
.store
.find_template_by_name_version(&request.name, &request.version)
.await?
{
existing
} else {
state
.store
.create_template(
Some(org_ctx.org_id),
&request.name,
&request.slug,
&request.description,
author_id,
&request.version,
request.category,
request.content_json.clone(),
)
.await?
};
TemplateVersion::create(
pool,
template.id,
&request.version,
request.content_json,
Some(&download_url),
Some(&request.checksum),
request.file_size,
)
.await
.map_err(ApiError::Database)?;
UsageCounter::update_storage(pool, org_ctx.org_id, new_storage)
.await
.map_err(ApiError::Database)?;
state
.store
.record_feature_usage(
org_ctx.org_id,
Some(author_id),
FeatureType::TemplatePublish,
Some(serde_json::json!({
"template_name": request.name,
"version": request.version,
})),
)
.await;
let ip_address = headers
.get("X-Forwarded-For")
.or_else(|| headers.get("X-Real-IP"))
.and_then(|h| h.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim());
let user_agent = headers.get("User-Agent").and_then(|h| h.to_str().ok());
state
.store
.record_audit_event(
org_ctx.org_id,
Some(author_id),
AuditEventType::TemplatePublished,
format!("Template {} version {} published", request.name, request.version),
Some(serde_json::json!({
"template_name": request.name,
"version": request.version,
})),
ip_address,
user_agent,
)
.await;
metrics.record_publish_success();
Ok(Json(PublishTemplateResponse {
name: request.name,
version: request.version,
download_url,
published_at: chrono::Utc::now().to_rfc3339(),
}))
}
pub async fn toggle_template_star(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((name, version)): Path<(String, String)>,
) -> ApiResult<Json<StarToggleResponse>> {
let template = state
.store
.find_template_by_name_version(&name, &version)
.await?
.ok_or_else(|| ApiError::TemplateNotFound(format!("{}@{}", name, version)))?;
let (starred, stars) = state.store.toggle_template_star(template.id, user_id).await?;
Ok(Json(StarToggleResponse { starred, stars }))
}
pub async fn get_template_star_state(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((name, version)): Path<(String, String)>,
) -> ApiResult<Json<StarStateResponse>> {
let template = state
.store
.find_template_by_name_version(&name, &version)
.await?
.ok_or_else(|| ApiError::TemplateNotFound(format!("{}@{}", name, version)))?;
let starred = state.store.is_template_starred_by(template.id, user_id).await?;
let stars = state.store.count_template_stars(template.id).await?;
Ok(Json(StarStateResponse { starred, stars }))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StarToggleResponse {
pub starred: bool,
pub stars: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StarStateResponse {
pub starred: bool,
pub stars: i64,
}
#[derive(Debug, Deserialize)]
pub struct TemplateSearchQuery {
pub query: Option<String>,
pub category: Option<String>,
pub tags: Vec<String>,
#[serde(default = "default_page")]
pub page: usize,
#[serde(default = "default_per_page")]
pub per_page: usize,
}
fn default_page() -> usize {
0
}
fn default_per_page() -> usize {
20
}
#[derive(Debug, Serialize)]
pub struct TemplateSearchResults {
pub templates: Vec<TemplateRegistryEntry>,
pub total: usize,
pub page: usize,
pub per_page: usize,
}
#[derive(Debug, Serialize)]
pub struct TemplateRegistryEntry {
pub id: String,
pub name: String,
pub description: String,
pub author: String,
pub author_email: Option<String>,
pub version: String,
#[serde(rename = "category")]
pub category: TemplateCategory,
pub tags: Vec<String>,
pub content: serde_json::Value,
pub readme: Option<String>,
pub example_usage: Option<String>,
pub requirements: Vec<String>,
pub compatibility: CompatibilityInfo,
pub stats: TemplateStats,
pub created_at: String,
pub updated_at: String,
pub published: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CompatibilityInfo {
pub min_version: String,
pub max_version: Option<String>,
pub required_features: Vec<String>,
pub protocols: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TemplateStats {
pub downloads: u64,
pub stars: u64,
pub forks: u64,
pub rating: f64,
pub rating_count: u64,
}
#[derive(Debug, Deserialize)]
pub struct PublishTemplateRequest {
pub name: String,
pub slug: String,
pub description: String,
pub version: String,
pub category: TemplateCategory,
pub content_json: serde_json::Value,
pub package: String, pub checksum: String,
pub file_size: i64,
}
#[derive(Debug, Serialize)]
pub struct PublishTemplateResponse {
pub name: String,
pub version: String,
pub download_url: String,
pub published_at: String,
}