use axum::extract::{Path, State};
use axum::{Extension, Json};
use serde_json::Value;
use crate::errors::OrionError;
use crate::server::admin_auth::AdminPrincipal;
use crate::server::extract::{OrionJson, OrionQuery};
use crate::server::routes::openapi::{DataEnvelope, PaginatedEnvelope};
use crate::server::routes::response_helpers::{data_response, paginated_into};
use crate::server::state::AppState;
use crate::storage::models::{PackageReceiptResponse, PackageState};
use crate::storage::repositories::helpers::{VersionFilter, clamp_pagination};
use crate::storage::repositories::packages::PutPackageReceiptRequest;
use super::audit_log;
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct PackageDetail {
name: String,
current: Option<PackageReceiptResponse>,
versions: Vec<PackageReceiptResponse>,
}
const MAX_NAME_LEN: usize = 128;
const MAX_VERSION_LEN: usize = 64;
const MAX_HASH_LEN: usize = 128;
fn validate_key_field(field: &str, value: &str, max_len: usize) -> Result<(), OrionError> {
if value.trim().is_empty() {
return Err(OrionError::validation(format!("{field} must not be empty")));
}
if value.len() > max_len {
return Err(OrionError::validation(format!(
"{field} must be at most {max_len} characters, got {}",
value.len()
)));
}
if let Some(bad) = value
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
{
return Err(OrionError::validation(format!(
"{field} contains unsupported character '{bad}' — use letters, digits, \
'.', '_' and '-'"
)));
}
Ok(())
}
fn validate_put(name: &str, req: &PutPackageReceiptRequest) -> Result<(), OrionError> {
validate_key_field("package name", name, MAX_NAME_LEN)?;
validate_key_field("version", &req.version, MAX_VERSION_LEN)?;
validate_key_field(
"content_hash",
&req.content_hash.replacen(':', "", 1),
MAX_HASH_LEN,
)?;
Ok(())
}
#[utoipa::path(
get,
path = "/api/v1/admin/packages",
tag = "Packages",
params(VersionFilter),
responses(
(status = 200, description = "Paginated receipt rows, ordered by package name, \
newest first within a package.", body = PaginatedEnvelope<PackageReceiptResponse>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_packages(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<VersionFilter>,
) -> Result<Json<Value>, OrionError> {
let (limit, offset) = clamp_pagination(filter.limit, filter.offset);
let result = state.repos.packages.list(limit, offset).await?;
paginated_into(result, |r| {
Ok::<_, OrionError>(PackageReceiptResponse::from(r))
})
}
#[utoipa::path(
get,
path = "/api/v1/admin/packages/{name}",
tag = "Packages",
params(("name" = String, Path, description = "Package name")),
responses(
(status = 200, description = "The package's receipts, with `current` naming the \
newest applied version.", body = DataEnvelope<PackageDetail>),
(status = 404, description = "No receipts recorded for this package"),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn get_package(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Value>, OrionError> {
let rows = state.repos.packages.get_by_name(&name).await?;
let current = rows
.iter()
.find(|r| r.state == PackageState::Applied.as_str())
.map(PackageReceiptResponse::from);
Ok(data_response(PackageDetail {
name,
current,
versions: rows.iter().map(PackageReceiptResponse::from).collect(),
}))
}
#[utoipa::path(
put,
path = "/api/v1/admin/packages/{name}",
tag = "Packages",
params(("name" = String, Path, description = "Package name")),
request_body = PutPackageReceiptRequest,
responses(
(status = 200, description = "The receipt as stored", body = DataEnvelope<PackageReceiptResponse>),
(status = 400, description = "Invalid name, version, content hash, or state"),
(status = 409, description = "The version is already applied with different \
content (an applied package version is immutable — bump the package \
version), already applied and asked to go back to staged, or was written \
by a concurrent request."),
)
)]
#[tracing::instrument(skip(state, req, principal))]
pub(crate) async fn put_package(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(name): Path<String>,
OrionJson(req): OrionJson<PutPackageReceiptRequest>,
) -> Result<Json<Value>, OrionError> {
validate_put(&name, &req)?;
let who = principal
.as_ref()
.map(|e| e.0.key_id.as_str())
.unwrap_or(super::ANONYMOUS_PRINCIPAL);
let receipt = state.repos.packages.put(&name, &req, who).await?;
audit_log(
&state.audit_queue,
&principal,
&format!("package_{}", req.state),
"package",
&format!("{name}@{}", req.version),
);
Ok(data_response(PackageReceiptResponse::from(&receipt)))
}