1#![forbid(unsafe_code)]
3
4use crate::{
5 MultipartPartGrant, MultipartPartReceipt, MultipartUploadGrant, ObjectByteRange,
6 ObjectDownloadGrant, ObjectKey, ObjectValidationState, PresignedObjectRequest,
7};
8use async_trait::async_trait;
9use axum::{
10 Extension, Json, Router,
11 extract::{DefaultBodyLimit, Path, State},
12 response::{IntoResponse, Response},
13 routing::{delete, get, post},
14};
15use http::{HeaderMap, StatusCode, header};
16use minco_core::{OperationDescriptor, PluginId};
17use minco_http::{
18 ApiFailure, ApiResponseMetadata, BearerChallenge, HttpModule, Principal, REQUEST_ID_HEADER,
19 StrongEntityTag, parse_if_match,
20};
21use serde::{Deserialize, Serialize};
22use std::{collections::BTreeMap, fmt, sync::Arc};
23use uuid::Uuid;
24
25pub const OBJECT_TRANSFER_BASE_PATH: &str = "/_minco/objects";
26pub const OBJECT_TRANSFER_HTTP_BODY_BYTES: usize = 3 * 1024 * 1024;
29pub const MAX_MULTIPART_ENTITY_TAG_BYTES: usize = 64;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ObjectTransferRequestContext {
33 pub request_id: String,
34 pub principal: Principal,
35 pub idempotency_key: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct InitiateTransferUpload {
41 pub purpose: String,
44 pub content_type: String,
45 pub size_bytes: u64,
46 pub sha256: Option<String>,
47 pub file_name: Option<String>,
48 pub replaces_object_id: Option<String>,
51 #[serde(skip)]
52 pub if_match: Option<String>,
53 #[serde(default)]
54 pub attributes: BTreeMap<String, String>,
55}
56
57#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct SingleTransferUploadGrant {
59 pub upload_id: Uuid,
60 pub key: ObjectKey,
61 pub request: PresignedObjectRequest,
62}
63
64impl fmt::Debug for SingleTransferUploadGrant {
65 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66 formatter
67 .debug_struct("SingleTransferUploadGrant")
68 .field("upload_id", &self.upload_id)
69 .field("key", &self.key)
70 .field("request", &self.request)
71 .finish()
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "mode", rename_all = "snake_case")]
77pub enum TransferUploadGrant {
78 Single(SingleTransferUploadGrant),
79 Multipart(MultipartUploadGrant),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct TransferUploadResponse {
84 pub upload: TransferUploadGrant,
85 pub validation: ObjectValidationState,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct IssueTransferPart {
91 pub sha256: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct CompleteTransferUpload {
97 #[serde(default)]
98 pub parts: Vec<MultipartPartReceipt>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct CompletedTransferUpload {
103 pub object_id: String,
106 pub revision: String,
107 pub entity_tag: String,
108 pub validation: ObjectValidationState,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct IssueTransferDownload {
114 pub object_id: String,
115 pub range: Option<ObjectByteRange>,
116 pub expected_entity_tag: Option<String>,
117 pub download_file_name: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ObjectTransferMetadata {
122 pub object_id: String,
123 pub revision: String,
124 pub content_type: String,
125 pub size_bytes: u64,
126 pub entity_tag: String,
131 pub last_modified: chrono::DateTime<chrono::Utc>,
132 pub validation: ObjectValidationState,
133}
134
135#[async_trait]
138pub trait ObjectTransferHttpUseCases: Send + Sync + fmt::Debug {
139 async fn initiate_upload(
140 &self,
141 context: ObjectTransferRequestContext,
142 request: InitiateTransferUpload,
143 ) -> Result<TransferUploadResponse, ObjectTransferApiError>;
144
145 async fn issue_part(
146 &self,
147 context: ObjectTransferRequestContext,
148 upload_id: Uuid,
149 part_number: u32,
150 request: IssueTransferPart,
151 ) -> Result<MultipartPartGrant, ObjectTransferApiError>;
152
153 async fn complete_upload(
154 &self,
155 context: ObjectTransferRequestContext,
156 upload_id: Uuid,
157 request: CompleteTransferUpload,
158 ) -> Result<CompletedTransferUpload, ObjectTransferApiError>;
159
160 async fn abort_upload(
161 &self,
162 context: ObjectTransferRequestContext,
163 upload_id: Uuid,
164 ) -> Result<(), ObjectTransferApiError>;
165
166 async fn issue_download(
167 &self,
168 context: ObjectTransferRequestContext,
169 request: IssueTransferDownload,
170 ) -> Result<ObjectDownloadGrant, ObjectTransferApiError>;
171
172 async fn get_metadata(
173 &self,
174 context: ObjectTransferRequestContext,
175 object_id: String,
176 ) -> Result<ObjectTransferMetadata, ObjectTransferApiError>;
177}
178
179#[derive(Clone)]
180pub struct ObjectTransferHttpService(Arc<dyn ObjectTransferHttpUseCases>);
181
182impl ObjectTransferHttpService {
183 pub fn new(use_cases: Arc<dyn ObjectTransferHttpUseCases>) -> Self {
184 Self(use_cases)
185 }
186}
187
188impl fmt::Debug for ObjectTransferHttpService {
189 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190 formatter.debug_tuple("ObjectTransferHttpService").finish()
191 }
192}
193
194pub fn object_transfer_operations() -> Vec<OperationDescriptor> {
195 [
196 (
197 "initiateObjectUpload",
198 "POST",
199 "/_minco/objects/uploads",
200 false,
201 ),
202 (
203 "issueObjectUploadPart",
204 "POST",
205 "/_minco/objects/uploads/{uploadId}/parts/{partNumber}",
206 true,
207 ),
208 (
209 "completeObjectUpload",
210 "POST",
211 "/_minco/objects/uploads/{uploadId}/complete",
212 true,
213 ),
214 (
215 "abortObjectUpload",
216 "DELETE",
217 "/_minco/objects/uploads/{uploadId}",
218 true,
219 ),
220 (
221 "issueObjectDownload",
222 "POST",
223 "/_minco/objects/downloads",
224 true,
225 ),
226 (
227 "getObjectTransferMetadata",
228 "GET",
229 "/_minco/objects/{objectId}",
230 true,
231 ),
232 ]
233 .into_iter()
234 .map(
235 |(operation_id, method, path, idempotent)| OperationDescriptor {
236 operation_id: operation_id.into(),
237 method: method.into(),
238 path: path.into(),
239 public: false,
240 idempotent,
241 },
242 )
243 .collect()
244}
245
246pub fn object_transfer_http_module(
247 plugin_id: PluginId,
248 service: ObjectTransferHttpService,
249) -> HttpModule {
250 HttpModule::new(plugin_id, object_transfer_router(service))
251 .with_operations(
252 object_transfer_operations()
253 .into_iter()
254 .map(|operation| operation.operation_id),
255 )
256 .with_max_request_body_bytes(OBJECT_TRANSFER_HTTP_BODY_BYTES)
257}
258
259pub fn object_transfer_router(service: ObjectTransferHttpService) -> Router {
260 let routes = Router::new()
261 .route("/uploads", post(initiate_upload))
262 .route("/uploads/{upload_id}/parts/{part_number}", post(issue_part))
263 .route("/uploads/{upload_id}/complete", post(complete_upload))
264 .route("/uploads/{upload_id}", delete(abort_upload))
265 .route("/downloads", post(issue_download))
266 .route("/{object_id}", get(get_metadata))
267 .layer(DefaultBodyLimit::max(OBJECT_TRANSFER_HTTP_BODY_BYTES))
268 .with_state(service);
269 Router::new().nest(OBJECT_TRANSFER_BASE_PATH, routes)
270}
271
272async fn initiate_upload(
273 State(service): State<ObjectTransferHttpService>,
274 principal: Option<Extension<Principal>>,
275 headers: HeaderMap,
276 Json(mut request): Json<InitiateTransferUpload>,
277) -> Result<Response, Response> {
278 let context = context(principal, &headers)?;
279 validate_initiate(&request, &context.request_id)?;
280 request.if_match = if headers.contains_key(header::IF_MATCH) {
281 let tag = parse_if_match(&headers)
282 .map_err(|_| ApiFailure::invalid_if_match(&context.request_id).into_response())?;
283 Some(
284 tag.to_header_value()
285 .to_str()
286 .expect("validated entity tag is ASCII")
287 .to_owned(),
288 )
289 } else {
290 None
291 };
292 if request.replaces_object_id.is_some() && request.if_match.is_none() {
293 return Err(ApiFailure::precondition_required(context.request_id).into_response());
294 }
295 let response = service
296 .0
297 .initiate_upload(context.clone(), request)
298 .await
299 .map_err(|error| api_error(error, context.request_id))?;
300 Ok((StatusCode::CREATED, Json(response)).into_response())
301}
302
303async fn issue_part(
304 State(service): State<ObjectTransferHttpService>,
305 principal: Option<Extension<Principal>>,
306 headers: HeaderMap,
307 Path((upload_id, part_number)): Path<(String, String)>,
308 Json(request): Json<IssueTransferPart>,
309) -> Result<Response, Response> {
310 let context = context(principal, &headers)?;
311 let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
312 let part_number = part_number.parse::<u32>().map_err(|_| {
313 ApiFailure::validation("part_number must be an integer", &context.request_id)
314 .into_response()
315 })?;
316 if !(1..=crate::MAX_MULTIPART_PARTS).contains(&part_number) || !valid_sha256(&request.sha256) {
317 return Err(ApiFailure::validation(
318 "part_number or SHA-256 is invalid",
319 &context.request_id,
320 )
321 .into_response());
322 }
323 let response = service
324 .0
325 .issue_part(context.clone(), upload_id, part_number, request)
326 .await
327 .map_err(|error| api_error(error, context.request_id))?;
328 Ok(Json(response).into_response())
329}
330
331async fn complete_upload(
332 State(service): State<ObjectTransferHttpService>,
333 principal: Option<Extension<Principal>>,
334 headers: HeaderMap,
335 Path(upload_id): Path<String>,
336 Json(request): Json<CompleteTransferUpload>,
337) -> Result<Response, Response> {
338 let context = context(principal, &headers)?;
339 let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
340 if request.parts.len() > crate::MAX_MULTIPART_PARTS as usize
341 || request.parts.iter().any(|part| {
342 !(1..=crate::MAX_MULTIPART_PARTS).contains(&part.part_number)
343 || !valid_sha256(&part.sha256)
344 || part.entity_tag.is_empty()
345 || part.entity_tag.len() > MAX_MULTIPART_ENTITY_TAG_BYTES
346 || part.entity_tag.chars().any(char::is_control)
347 })
348 {
349 return Err(ApiFailure::validation(
350 "multipart completion manifest is invalid",
351 &context.request_id,
352 )
353 .into_response());
354 }
355 let response = service
356 .0
357 .complete_upload(context.clone(), upload_id, request)
358 .await
359 .map_err(|error| api_error(error, context.request_id))?;
360 Ok(Json(response).into_response())
361}
362
363async fn abort_upload(
364 State(service): State<ObjectTransferHttpService>,
365 principal: Option<Extension<Principal>>,
366 headers: HeaderMap,
367 Path(upload_id): Path<String>,
368) -> Result<Response, Response> {
369 let context = context(principal, &headers)?;
370 let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
371 service
372 .0
373 .abort_upload(context.clone(), upload_id)
374 .await
375 .map_err(|error| api_error(error, context.request_id))?;
376 Ok(StatusCode::NO_CONTENT.into_response())
377}
378
379async fn issue_download(
380 State(service): State<ObjectTransferHttpService>,
381 principal: Option<Extension<Principal>>,
382 headers: HeaderMap,
383 Json(request): Json<IssueTransferDownload>,
384) -> Result<Response, Response> {
385 let context = context(principal, &headers)?;
386 validate_download(&request, &context.request_id)?;
387 let response = service
388 .0
389 .issue_download(context.clone(), request)
390 .await
391 .map_err(|error| api_error(error, context.request_id))?;
392 Ok(Json(response).into_response())
393}
394
395async fn get_metadata(
396 State(service): State<ObjectTransferHttpService>,
397 principal: Option<Extension<Principal>>,
398 headers: HeaderMap,
399 Path(object_id): Path<String>,
400) -> Result<Response, Response> {
401 let context = context(principal, &headers)?;
402 if !valid_bounded_text(&object_id, 256) {
403 return Err(
404 ApiFailure::validation("object_id is invalid", context.request_id).into_response(),
405 );
406 }
407 let metadata = service
408 .0
409 .get_metadata(context.clone(), object_id)
410 .await
411 .map_err(|error| api_error(error, context.request_id.clone()))?;
412 let entity_tag = parse_application_entity_tag(&metadata.entity_tag)
413 .map_err(|()| ApiFailure::internal(context.request_id.clone()).into_response())?;
414 let not_modified = if_none_match_matches(&headers, entity_tag.opaque());
415 let mut response = if not_modified {
416 StatusCode::NOT_MODIFIED.into_response()
417 } else {
418 Json(metadata).into_response()
419 };
420 response
421 .headers_mut()
422 .insert(header::ETAG, entity_tag.to_header_value());
423 response.headers_mut().insert(
424 header::CACHE_CONTROL,
425 http::HeaderValue::from_static("private, no-cache"),
426 );
427 response.headers_mut().insert(
428 header::VARY,
429 http::HeaderValue::from_static("Authorization"),
430 );
431 Ok(response)
432}
433
434#[allow(clippy::result_large_err)]
435fn context(
436 principal: Option<Extension<Principal>>,
437 headers: &HeaderMap,
438) -> Result<ObjectTransferRequestContext, Response> {
439 let request_id = request_id(headers);
440 let Some(Extension(principal)) = principal else {
441 return Err(ApiResponseMetadata::new()
442 .bearer_challenge(BearerChallenge::Required)
443 .wrap(ApiFailure::new(
444 StatusCode::UNAUTHORIZED,
445 "authentication_required",
446 "Authentication required",
447 "A valid authenticated principal is required.",
448 request_id,
449 ))
450 .into_response());
451 };
452 let idempotency_key = parse_idempotency_key(headers, &request_id)?;
453 Ok(ObjectTransferRequestContext {
454 request_id,
455 principal,
456 idempotency_key,
457 })
458}
459
460fn request_id(headers: &HeaderMap) -> String {
461 headers
462 .get(&REQUEST_ID_HEADER)
463 .and_then(|value| value.to_str().ok())
464 .filter(|value| valid_bounded_text(value, 200))
465 .map_or_else(|| Uuid::now_v7().to_string(), str::to_owned)
466}
467
468#[allow(clippy::result_large_err)]
469fn parse_uuid(value: &str, field: &str, request_id: &str) -> Result<Uuid, Response> {
470 Uuid::parse_str(value).map_err(|_| {
471 ApiFailure::validation(format!("{field} must be a UUID"), request_id).into_response()
472 })
473}
474
475#[allow(clippy::result_large_err)]
476fn parse_idempotency_key(
477 headers: &HeaderMap,
478 request_id: &str,
479) -> Result<Option<String>, Response> {
480 let mut values = headers.get_all("idempotency-key").iter();
481 let Some(value) = values.next() else {
482 return Ok(None);
483 };
484 if values.next().is_some() {
485 return Err(
486 ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response(),
487 );
488 }
489 let value = value.to_str().map_err(|_| {
490 ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response()
491 })?;
492 if !valid_bounded_text(value, 200) {
493 return Err(
494 ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response(),
495 );
496 }
497 Ok(Some(value.to_owned()))
498}
499
500#[allow(clippy::result_large_err)]
501fn validate_initiate(request: &InitiateTransferUpload, request_id: &str) -> Result<(), Response> {
502 let valid_purpose = !request.purpose.is_empty()
503 && request.purpose.len() <= 128
504 && request.purpose.bytes().all(|byte| {
505 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
506 });
507 let valid_type = request
508 .content_type
509 .split_once('/')
510 .is_some_and(|(top, subtype)| {
511 !top.is_empty()
512 && !subtype.is_empty()
513 && !subtype.contains('/')
514 && request.content_type.len() <= 255
515 && request.content_type.bytes().all(|byte| {
516 byte.is_ascii_alphanumeric()
517 || matches!(
518 byte,
519 b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
520 )
521 })
522 });
523 let valid_checksum = request.sha256.as_deref().is_none_or(valid_sha256);
524 let valid_file_name = request
525 .file_name
526 .as_deref()
527 .is_none_or(|value| valid_bounded_text(value, 255));
528 let valid_replacement = request
529 .replaces_object_id
530 .as_deref()
531 .is_none_or(|value| valid_bounded_text(value, 256));
532 let valid_attributes = request.attributes.len() <= 31
533 && request.attributes.iter().all(|(key, value)| {
534 valid_bounded_text(key, 128)
535 && !key.starts_with("minco.")
536 && value.len() <= 1_024
537 && !value.chars().any(char::is_control)
538 });
539 if valid_purpose
540 && valid_type
541 && request.size_bytes > 0
542 && valid_checksum
543 && valid_file_name
544 && valid_replacement
545 && valid_attributes
546 {
547 Ok(())
548 } else {
549 Err(
550 ApiFailure::validation("object upload declaration is invalid", request_id)
551 .into_response(),
552 )
553 }
554}
555
556#[allow(clippy::result_large_err)]
557fn validate_download(request: &IssueTransferDownload, request_id: &str) -> Result<(), Response> {
558 let valid = valid_bounded_text(&request.object_id, 256)
559 && request.range.is_none_or(|range| range.validate().is_ok())
560 && request
561 .expected_entity_tag
562 .as_deref()
563 .is_none_or(|value| valid_bounded_text(value, 256) && !value.starts_with("W/"))
564 && request
565 .download_file_name
566 .as_deref()
567 .is_none_or(|value| valid_bounded_text(value, 255));
568 if valid {
569 Ok(())
570 } else {
571 Err(
572 ApiFailure::validation("object download declaration is invalid", request_id)
573 .into_response(),
574 )
575 }
576}
577
578fn valid_sha256(value: &str) -> bool {
579 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
580}
581
582fn valid_bounded_text(value: &str, maximum: usize) -> bool {
583 !value.is_empty() && value.len() <= maximum && !value.chars().any(char::is_control)
584}
585
586fn parse_application_entity_tag(value: &str) -> Result<StrongEntityTag, ()> {
587 let opaque = value
588 .strip_prefix('"')
589 .and_then(|value| value.strip_suffix('"'))
590 .ok_or(())?;
591 StrongEntityTag::from_opaque(opaque).map_err(|_| ())
592}
593
594fn if_none_match_matches(headers: &HeaderMap, current_opaque: &str) -> bool {
595 headers.get_all(header::IF_NONE_MATCH).iter().any(|value| {
596 value.to_str().is_ok_and(|value| {
597 value.split(',').any(|candidate| {
598 let candidate = candidate.trim();
599 if candidate == "*" {
600 return true;
601 }
602 let candidate = candidate.strip_prefix("W/").unwrap_or(candidate);
603 candidate
604 .strip_prefix('"')
605 .and_then(|candidate| candidate.strip_suffix('"'))
606 .and_then(|opaque| StrongEntityTag::from_opaque(opaque).ok())
607 .is_some_and(|candidate| candidate.opaque() == current_opaque)
608 })
609 })
610 })
611}
612
613fn api_error(error: ObjectTransferApiError, request_id: String) -> Response {
614 let (status, code, title, detail) = match error {
615 ObjectTransferApiError::Forbidden => (
616 StatusCode::FORBIDDEN,
617 "object_transfer_forbidden",
618 "Object transfer forbidden",
619 "The principal is not authorized for this object transfer.",
620 ),
621 ObjectTransferApiError::NotFound => (
622 StatusCode::NOT_FOUND,
623 "object_transfer_not_found",
624 "Object transfer not found",
625 "The requested object or transfer session does not exist.",
626 ),
627 ObjectTransferApiError::Conflict => (
628 StatusCode::CONFLICT,
629 "object_transfer_conflict",
630 "Object transfer conflict",
631 "The transfer is not in a state that accepts this operation.",
632 ),
633 ObjectTransferApiError::PreconditionFailed => (
634 StatusCode::PRECONDITION_FAILED,
635 "precondition_failed",
636 "Precondition failed",
637 "The object changed after it was read. Fetch the current revision and retry.",
638 ),
639 ObjectTransferApiError::Validation(detail) => {
640 return ApiFailure::validation(detail, request_id).into_response();
641 }
642 ObjectTransferApiError::Expired => (
643 StatusCode::GONE,
644 "object_transfer_expired",
645 "Object transfer expired",
646 "The transfer session expired; start a new session.",
647 ),
648 ObjectTransferApiError::Unavailable => (
649 StatusCode::SERVICE_UNAVAILABLE,
650 "object_transfer_unavailable",
651 "Object transfer unavailable",
652 "The object provider is temporarily unavailable.",
653 ),
654 ObjectTransferApiError::Internal => (
655 StatusCode::INTERNAL_SERVER_ERROR,
656 "internal_error",
657 "Internal server error",
658 "The request could not be completed.",
659 ),
660 };
661 ApiFailure::new(status, code, title, detail, request_id).into_response()
662}
663
664#[non_exhaustive]
665#[derive(Debug, thiserror::Error)]
666pub enum ObjectTransferApiError {
667 #[error("object transfer is forbidden")]
668 Forbidden,
669 #[error("object or transfer session was not found")]
670 NotFound,
671 #[error("object transfer state conflicts with the request")]
672 Conflict,
673 #[error("object transfer precondition failed")]
674 PreconditionFailed,
675 #[error("object transfer validation failed: {0}")]
676 Validation(String),
677 #[error("object transfer session expired")]
678 Expired,
679 #[error("object provider is unavailable")]
680 Unavailable,
681 #[error("object transfer failed internally")]
682 Internal,
683}