Files
crank/apps/admin-api/src/routes/imports.rs
T
bsodfather bc03c33387
CI / Rust Checks (push) Failing after 4m6s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
fix(openapi): harden story 2.1 production lifecycle
2026-08-29 00:48:22 +03:00

222 lines
7.4 KiB
Rust

use axum::{
Json,
extract::{Multipart, Path, State, multipart::MultipartRejection},
http::HeaderMap,
response::IntoResponse,
};
use crank_artifacts::MAX_ARTIFACT_BYTES;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{OpenApiImportCreatePayload, OpenApiUpload, OpenApiUploadLocale},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceImportPath {
pub workspace_id: String,
pub job_id: String,
}
pub async fn preview_openapi_import(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
headers: HeaderMap,
multipart: Result<Multipart, MultipartRejection>,
) -> Result<Json<Value>, ApiError> {
let locale = openapi_upload_locale(&headers);
let upload = parse_openapi_upload(
multipart.map_err(|rejection| multipart_rejection(locale, rejection))?,
locale,
)
.await?;
let preview = state
.service
.preview_openapi_import(&path.workspace_id.as_str().into(), upload)
.await?;
Ok(Json(json!(preview)))
}
fn multipart_rejection(locale: OpenApiUploadLocale, rejection: MultipartRejection) -> ApiError {
if rejection.into_response().status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
ApiError::openapi_upload(locale, "file_too_large")
} else {
ApiError::openapi_upload(locale, "malformed_multipart")
}
}
async fn parse_openapi_upload(
mut multipart: Multipart,
locale: OpenApiUploadLocale,
) -> Result<OpenApiUpload, ApiError> {
let mut upload = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
{
if upload.is_some() || field.name() != Some("file") {
return Err(ApiError::openapi_upload(locale, "malformed_multipart"));
}
let filename = field
.file_name()
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_filename"))?;
let mime_type = field
.content_type()
// Axum exposes the raw header value here. Persist only the MIME
// essence so parameters and case cannot make route and service
// validation disagree.
.map(|mime| {
mime.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
})
.ok_or_else(|| ApiError::openapi_upload(locale, "invalid_media_type"))?;
if !valid_upload_type(filename, &mime_type) {
return Err(ApiError::openapi_upload(locale, "invalid_media_type"));
}
let mut bytes = Vec::with_capacity(8 * 1024);
let mut field = field;
while let Some(chunk) = field
.chunk()
.await
.map_err(|_| ApiError::openapi_upload(locale, "malformed_multipart"))?
{
if bytes.len().saturating_add(chunk.len()) > MAX_ARTIFACT_BYTES {
return Err(ApiError::openapi_upload(locale, "file_too_large"));
}
bytes.extend_from_slice(&chunk);
}
if bytes.is_empty() {
return Err(ApiError::openapi_upload(locale, "empty_file"));
}
if std::str::from_utf8(&bytes).is_err() {
return Err(ApiError::openapi_upload(locale, "invalid_utf8"));
}
upload = Some(OpenApiUpload {
bytes,
mime_type,
locale,
});
}
upload.ok_or_else(|| ApiError::openapi_upload(locale, "missing_file"))
}
fn valid_upload_type(filename: &str, mime_type: &str) -> bool {
let filename = filename.to_ascii_lowercase();
let mime_type = mime_type.to_ascii_lowercase();
match filename.rsplit_once('.') {
Some((_, "yaml" | "yml")) => matches!(
mime_type.as_str(),
"application/yaml"
| "application/x-yaml"
| "text/yaml"
| "text/x-yaml"
| "application/octet-stream"
),
Some((_, "json")) => matches!(
mime_type.as_str(),
"application/json" | "application/openapi+json" | "application/octet-stream"
),
_ => false,
}
}
fn openapi_upload_locale(headers: &HeaderMap) -> OpenApiUploadLocale {
let Some(value) = headers
.get("accept-language")
.and_then(|value| value.to_str().ok())
else {
return OpenApiUploadLocale::En;
};
// RFC 9110: highest q wins; ties preserve the header's order. Only the
// locales served by this endpoint participate in negotiation.
let mut preferred = (0_u16, usize::MAX, OpenApiUploadLocale::En);
for (index, range) in value.split(',').enumerate() {
let mut parts = range.split(';');
let language = parts.next().unwrap_or_default().trim();
let locale = if language.eq_ignore_ascii_case("ru")
|| language.to_ascii_lowercase().starts_with("ru-")
{
Some(OpenApiUploadLocale::Ru)
} else if language.eq_ignore_ascii_case("en")
|| language.to_ascii_lowercase().starts_with("en-")
|| language == "*"
{
Some(OpenApiUploadLocale::En)
} else {
None
};
let Some(locale) = locale else { continue };
let quality = match parts
.filter_map(|parameter| {
let (name, value) = parameter.trim().split_once('=')?;
name.eq_ignore_ascii_case("q").then_some(value.trim())
})
.next()
{
None => Some(1_000_u16),
Some(value) => value
.parse::<f32>()
.ok()
.filter(|quality| (0.0..=1.0).contains(quality) && *quality > 0.0)
.map(|quality| (quality * 1_000.0).round() as u16),
};
let Some(quality) = quality else { continue };
if quality > preferred.0 || (quality == preferred.0 && index < preferred.1) {
preferred = (quality, index, locale);
}
}
preferred.2
}
pub async fn create_openapi_import(
Path(path): Path<WorkspaceImportPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<OpenApiImportCreatePayload>,
) -> Result<Json<Value>, ApiError> {
let imported = state
.service
.create_openapi_import_with_locale(
&path.workspace_id.as_str().into(),
&path.job_id.as_str().into(),
payload,
openapi_upload_locale(&headers),
)
.await?;
Ok(Json(json!(imported)))
}
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use super::*;
fn locale(value: &str) -> OpenApiUploadLocale {
let mut headers = HeaderMap::new();
headers.insert("accept-language", HeaderValue::from_str(value).unwrap());
openapi_upload_locale(&headers)
}
#[test]
fn accept_language_honours_quality_zero_and_header_order() {
assert_eq!(locale("ru;q=0, en;q=0.5"), OpenApiUploadLocale::En);
assert_eq!(locale("en;q=0.5, ru;q=0.5"), OpenApiUploadLocale::En);
assert_eq!(locale("ru-RU;q=0.9, en;q=1"), OpenApiUploadLocale::En);
assert_eq!(locale("ru, en;q=0.5"), OpenApiUploadLocale::Ru);
assert_eq!(locale("ru;q=bad, en;q=0.5"), OpenApiUploadLocale::En);
}
}