feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix (#163)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s

This commit was merged in pull request #163.
This commit is contained in:
2026-07-16 09:41:12 +00:00
parent 6d02b138c6
commit afa5c985ee
7 changed files with 376 additions and 42 deletions
+111 -1
View File
@@ -5,7 +5,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Extension, Path, Query};
use axum::extract::{Extension, Multipart, Path, Query};
use axum::http::StatusCode;
use axum::Json;
use mongodb::bson::{doc, oid::ObjectId, to_bson};
@@ -377,6 +377,116 @@ pub async fn add_artifact(
get_target(Extension(agent), tenant, Path(id)).await
}
/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading
/// its file (PLC project, firmware image, source archive, mobile package). The
/// bytes are written to the artifact blob store and referenced by `stored_path`,
/// so ingest resolves them locally (no URL fetch).
///
/// Multipart fields: `file` (required), `kind` (required, snake_case
/// `ArtifactKind`), `plc_format` (optional, for PLC projects).
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn upload_artifact(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
if db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_none()
{
return Err(StatusCode::NOT_FOUND);
}
let mut kind: Option<ArtifactKind> = None;
let mut plc_format: Option<PlcFormat> = None;
let mut filename = String::from("upload.bin");
let mut bytes: Option<axum::body::Bytes> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
{
match field.name().unwrap_or("") {
"kind" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
kind = parse_enum(&v);
}
"plc_format" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
plc_format = parse_enum(&v);
}
"file" => {
if let Some(fname) = field.file_name() {
filename = fname.to_string();
}
bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
_ => {}
}
}
let (Some(kind), Some(bytes)) = (kind, bytes) else {
return Err(StatusCode::BAD_REQUEST);
};
// Store the uploaded bytes under the artifact blob store.
let safe_name: String = filename
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
let dir = std::path::Path::new(&agent.config.artifact_store_base_path)
.join("uploads")
.join(&id);
std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4()));
std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build the artifact for this kind, referencing the stored file.
let mut artifact = match kind {
ArtifactKind::PlcProject => Artifact::plc_project(
filename.clone(),
plc_format.unwrap_or(PlcFormat::PlcopenXml),
),
ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()),
ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()),
ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()),
// Non-file kinds (git repo, live URL, container ref, text) use the JSON
// add-artifact endpoint, not upload.
_ => return Err(StatusCode::BAD_REQUEST),
};
artifact.stored_path = Some(dest.to_string_lossy().to_string());
artifact.size_bytes = Some(bytes.len() as u64);
let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_target(Extension(agent), tenant, Path(id)).await
}
/// Deserialize a snake_case enum value from a plain string.
fn parse_enum<T: for<'de> Deserialize<'de>>(s: &str) -> Option<T> {
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
}
/// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn applicable_scans_for_target(
+4
View File
@@ -26,6 +26,10 @@ pub fn build_router() -> Router {
"/api/v1/targets/{id}/artifacts",
post(handlers::onboarding::add_artifact),
)
.route(
"/api/v1/targets/{id}/artifacts/upload",
post(handlers::onboarding::upload_artifact),
)
.route(
"/api/v1/targets/{id}/applicable-scans",
get(handlers::onboarding::applicable_scans_for_target),