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::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>,
}
#[derive(Debug, Default, serde::Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub(crate) struct PackageListQuery {
pub limit: Option<i64>,
pub offset: Option<i64>,
#[serde(default)]
pub current: bool,
}
const MAX_INVENTORY_IDS: usize = super::MAX_IMPORT_ITEMS;
const MAX_INVENTORY_ID_LEN: usize = 255;
fn validate_key_field(field: &str, value: &str, max_len: usize) -> Result<(), OrionError> {
crate::validation::package_key(field, value, max_len).map_err(OrionError::validation)
}
fn validate_put(name: &str, req: &PutPackageReceiptRequest) -> Result<(), OrionError> {
validate_key_field(
"package name",
name,
crate::validation::MAX_PACKAGE_NAME_LEN,
)?;
validate_key_field(
"version",
&req.version,
crate::validation::MAX_PACKAGE_VERSION_LEN,
)?;
validate_key_field(
"content_hash",
&req.content_hash.replacen(':', "", 1),
crate::validation::MAX_PACKAGE_HASH_LEN,
)?;
if let Some(inventory) = &req.inventory {
for (kind, ids) in inventory.kinds() {
if ids.len() > MAX_INVENTORY_IDS {
return Err(OrionError::validation(format!(
"inventory.{kind} lists {} ids — at most {MAX_INVENTORY_IDS}",
ids.len()
)));
}
if let Some(bad) = ids
.iter()
.find(|id| id.is_empty() || id.len() > MAX_INVENTORY_ID_LEN)
{
return Err(OrionError::validation(format!(
"inventory.{kind} holds an id of {} bytes — each must be 1 to \
{MAX_INVENTORY_ID_LEN}",
bad.len()
)));
}
}
}
Ok(())
}
#[utoipa::path(
get,
path = "/api/v1/admin/packages",
tag = "Packages",
params(PackageListQuery),
responses(
(status = 200, description = "Paginated receipt rows, ordered by package name, \
newest first within a package, without their `inventory`. With \
`?current=true`, each package's current receipt with its `inventory`.",
body = PaginatedEnvelope<PackageReceiptResponse>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_packages(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<PackageListQuery>,
) -> Result<Json<Value>, OrionError> {
let (limit, offset) = clamp_pagination(filter.limit, filter.offset);
if filter.current {
let result = state.repos.packages.list_current(limit, offset).await?;
return paginated_into(result, |r| {
Ok::<_, OrionError>(PackageReceiptResponse::from(r))
});
}
let result = state.repos.packages.list(limit, offset).await?;
paginated_into(result, |r| {
Ok::<_, OrionError>(PackageReceiptResponse {
inventory: None,
..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, state, or \
inventory (more than 1000 ids in one list, or an empty or over-long id)"),
(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)))
}