minna-caos/src/http_api/upload/append_to_upload.rs
2025-04-17 00:53:56 +02:00

381 lines
14 KiB
Rust

use crate::http_api::Context;
use crate::http_api::api_error::{ApiError, ProblemJson};
use crate::http_api::headers::{HeaderMapExt, HeaderValueExt, upload_headers};
use crate::http_api::upload::{PARTIAL_UPLOAD_MEDIA_TYPE, UploadCompleteResponseHeader, UploadOffsetResponseHeader};
use crate::upload_manager::{AnyStageUpload, FileReference, UnfinishedUpload, UploadFailureReason, UploadId, UploadManager};
use crate::util::acquirable::Acquisition;
use axum::Json;
use axum::body::{Body, BodyDataStream};
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use color_eyre::Report;
use futures::TryStreamExt;
use serde::Deserialize;
use serde_json::json;
use std::borrow::Cow;
use std::io::ErrorKind;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::StreamReader;
#[derive(Debug, Deserialize)]
pub(super) struct AppendToUploadPathParameters {
upload_id: UploadId,
}
#[derive(Debug)]
enum AppendToUploadOutcome {
RequestSuperseded,
UploadAlreadyComplete,
Failed(UploadFailureReason),
UploadOffsetMismatch { expected: u64, provided: u64 },
InconsistentUploadLength { expected: u64, detail: Cow<'static, str> },
ContentStreamStoppedUnexpectedly,
TooMuchContent,
UploadIncomplete { offset: u64 },
UploadComplete,
}
impl IntoResponse for AppendToUploadOutcome {
fn into_response(self) -> Response {
match self {
AppendToUploadOutcome::RequestSuperseded => (
StatusCode::CONFLICT,
UploadCompleteResponseHeader(false),
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/request-superseded",
"title": "Another request superseded the current request.",
})),
)
.into_response(),
AppendToUploadOutcome::UploadAlreadyComplete => (
StatusCode::CONFLICT,
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#completed-upload",
"title": "The upload is already complete.",
})),
)
.into_response(),
AppendToUploadOutcome::Failed(reason) => (
StatusCode::GONE,
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/request-superseded",
"title": "The upload was cancelled or failed.",
"reason": reason.to_string()
})),
)
.into_response(),
AppendToUploadOutcome::UploadOffsetMismatch { expected, provided } => (
StatusCode::CONFLICT,
UploadCompleteResponseHeader(false),
UploadOffsetResponseHeader(expected),
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#mismatching-upload-offset",
"title": "The upload offset provided in the request does not match the actual offset of the resource.",
"expected-offset": expected,
"provided-offset": provided,
})),
)
.into_response(),
AppendToUploadOutcome::InconsistentUploadLength { expected, detail } => (
StatusCode::CONFLICT,
UploadCompleteResponseHeader(false),
UploadOffsetResponseHeader(expected),
ProblemJson(json!({
"type": "https://iana.org/assignments/http-problem-types#inconsistent-upload-length",
"title": "The provided upload lengths are inconsistent with one another or a previously established total length.",
"detail": detail,
})),
)
.into_response(),
AppendToUploadOutcome::ContentStreamStoppedUnexpectedly => (
StatusCode::BAD_REQUEST,
UploadCompleteResponseHeader(false),
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/content-stream-stopped-unexpectedly",
"title": "The content stream stopped unexpectedly.",
})),
)
.into_response(),
AppendToUploadOutcome::TooMuchContent => (
StatusCode::BAD_REQUEST,
UploadCompleteResponseHeader(false),
ProblemJson(json!({
"type": "https://minna.media/api-problems/caos/too-much-content",
"title": "The request contained more content than it should.",
})),
)
.into_response(),
AppendToUploadOutcome::UploadIncomplete { offset } => (
StatusCode::NO_CONTENT,
UploadCompleteResponseHeader(false),
UploadOffsetResponseHeader(offset),
Body::empty(),
)
.into_response(),
AppendToUploadOutcome::UploadComplete => (StatusCode::NO_CONTENT, UploadCompleteResponseHeader(true), Body::empty()).into_response(),
}
}
}
pub(super) async fn append_to_upload(
State(context): State<Context>,
Path(AppendToUploadPathParameters { upload_id }): Path<AppendToUploadPathParameters>,
headers: HeaderMap,
request_body: Body,
) -> Result<impl IntoResponse, (UploadCompleteResponseHeader, ApiError)> {
let parameters = match parse_request_parameters(&context.upload_manager, upload_id, &headers)
.await
.map_err(|e| (UploadCompleteResponseHeader(false), e))?
{
Ok(p) => p,
Err(o) => return Ok(o),
};
{
let state = parameters.upload.state().read().await;
if state.is_complete() {
return Ok(AppendToUploadOutcome::UploadAlreadyComplete);
}
}
let mut file_acquisition = if let Some(a) = parameters.upload.acquire_file().await {
a
} else {
return Ok(AppendToUploadOutcome::RequestSuperseded);
};
let outcome = do_append(&mut file_acquisition, parameters, request_body.into_data_stream())
.await
.map_err(|report| {
(
UploadCompleteResponseHeader(false),
ApiError::Internal {
report: report.wrap_err(format!("Error during file upload ({upload_id})")),
},
)
})?;
file_acquisition.release().await;
Ok(outcome)
}
struct RequestParameters {
pub upload: Arc<UnfinishedUpload>,
pub supplied_content_length: Option<u64>,
pub supplied_upload_length: Option<u64>,
pub supplied_upload_offset: u64,
pub supplied_upload_complete: bool,
}
async fn parse_request_parameters(
upload_manager: &UploadManager,
upload_id: UploadId,
headers: &HeaderMap,
) -> Result<Result<RequestParameters, AppendToUploadOutcome>, ApiError> {
let upload = if let Some(upload) = upload_manager.get_upload_by_id(&upload_id).await? {
match upload {
AnyStageUpload::Unfinished(u) => u,
AnyStageUpload::Finished { .. } => return Ok(Err(AppendToUploadOutcome::UploadAlreadyComplete)),
AnyStageUpload::Failed(reason) => return Ok(Err(AppendToUploadOutcome::Failed(reason))),
}
} else {
return Err(ApiError::UnknownResource {
resource_type: "upload".into(),
id: upload_id.to_string().into(),
});
};
if !headers
.get_exactly_once(&axum::http::header::CONTENT_TYPE)?
.to_str()
.ok()
.map(|v| v == PARTIAL_UPLOAD_MEDIA_TYPE)
.unwrap_or(false)
{
return Err(ApiError::InvalidRequestHeader {
name: axum::http::header::CONTENT_TYPE,
message: format!("must be {}", PARTIAL_UPLOAD_MEDIA_TYPE.to_string()).into(),
});
}
let supplied_content_length = headers
.get_at_most_once(&axum::http::header::CONTENT_LENGTH)?
.map(|v| v.get_unsigned_decimal_number(&axum::http::header::CONTENT_LENGTH))
.transpose()?;
let supplied_upload_length = headers
.get_at_most_once(&upload_headers::UPLOAD_OFFSET)?
.map(|v| v.get_unsigned_decimal_number(&upload_headers::UPLOAD_OFFSET))
.transpose()?;
let supplied_upload_offset = headers
.get_exactly_once(&upload_headers::UPLOAD_OFFSET)?
.get_unsigned_decimal_number(&upload_headers::UPLOAD_OFFSET)?;
let supplied_upload_complete = headers
.get_exactly_once(&upload_headers::UPLOAD_COMPLETE)?
.get_boolean(&upload_headers::UPLOAD_COMPLETE)?;
Ok(Ok(RequestParameters {
upload,
supplied_content_length,
supplied_upload_length,
supplied_upload_offset,
supplied_upload_complete,
}))
}
async fn do_append(
file_acquisition: &mut Acquisition<FileReference>,
parameters: RequestParameters,
content_stream: BodyDataStream,
) -> Result<AppendToUploadOutcome, Report> {
let mut upload_state = parameters.upload.state().write().await;
let release_request_token = file_acquisition.release_request_token();
let mut file = file_acquisition.inner().get_or_open(true).await?;
let total_size = parameters.upload.total_size();
let current_offset = file.stream_position().await?;
if current_offset < upload_state.current_size() {
log::error!(
"The upload ({}) failed because the file contains less data than expected.",
parameters.upload.id()
);
parameters.upload.fail(UploadFailureReason::MissingData).await?;
return Ok(AppendToUploadOutcome::Failed(UploadFailureReason::MissingData));
}
let remaining_content_length = total_size - current_offset;
if parameters.supplied_upload_offset != current_offset {
return Ok(AppendToUploadOutcome::UploadOffsetMismatch {
expected: current_offset,
provided: parameters.supplied_upload_offset,
});
}
let payload_length_limit = if let Some(supplied_content_length) = parameters.supplied_content_length {
if parameters.supplied_upload_complete {
if remaining_content_length != supplied_content_length {
return Ok(AppendToUploadOutcome::InconsistentUploadLength {
expected: total_size,
detail: "Upload-Complete is set to true, and Content-Length is set, \
but the value of Content-Length does not equal the length of the remaining content."
.into(),
});
}
} else {
if supplied_content_length >= remaining_content_length {
return Ok(AppendToUploadOutcome::InconsistentUploadLength {
expected: total_size,
detail: "Upload-Complete is set to false, and Content-Length is set, \
but the value of Content-Length is not smaller than the length of the remaining content."
.into(),
});
}
}
supplied_content_length
} else {
remaining_content_length
};
let outcome = tokio::select! {
o = stream_to_file(
content_stream,
&mut file,
remaining_content_length,
parameters.supplied_content_length,
parameters.supplied_upload_complete,
payload_length_limit
) => Some(o?),
_ = release_request_token.cancelled() => None
};
file.sync_all().await?;
let new_size = file.stream_position().await?;
upload_state.set_current_size(new_size);
let is_upload_complete = if let Some(StreamToFileOutcome::Success) = outcome {
parameters.supplied_upload_complete
} else {
false
};
if is_upload_complete {
upload_state.set_complete();
parameters.upload.save_to_database(&upload_state).await?;
parameters.upload.enqueue_for_processing(&upload_state).await;
} else {
parameters.upload.save_to_database(&upload_state).await?;
}
Ok(if let Some(outcome) = outcome {
match outcome {
StreamToFileOutcome::StoppedUnexpectedly => AppendToUploadOutcome::ContentStreamStoppedUnexpectedly,
StreamToFileOutcome::TooMuchContent => AppendToUploadOutcome::TooMuchContent,
StreamToFileOutcome::Success => {
if is_upload_complete {
AppendToUploadOutcome::UploadComplete
} else {
AppendToUploadOutcome::UploadIncomplete { offset: new_size }
}
}
}
} else {
AppendToUploadOutcome::RequestSuperseded
})
}
#[derive(Debug)]
pub enum StreamToFileOutcome {
StoppedUnexpectedly,
TooMuchContent,
Success,
}
async fn stream_to_file(
content_stream: BodyDataStream,
file: &mut File,
remaining_content_length: u64,
supplied_content_length: Option<u64>,
supplied_upload_complete: bool,
payload_length_limit: u64,
) -> Result<StreamToFileOutcome, std::io::Error> {
let body_with_io_error = content_stream.into_stream().map_err(|err| std::io::Error::new(ErrorKind::Other, err));
let stream = StreamReader::new(body_with_io_error).take(payload_length_limit + 1);
futures::pin_mut!(stream);
match tokio::io::copy(&mut stream, file).await {
Ok(n) => {
if let Some(supplied_content_length) = supplied_content_length {
if n < supplied_content_length {
return Ok(StreamToFileOutcome::StoppedUnexpectedly);
}
} else {
if supplied_upload_complete {
if n < remaining_content_length {
return Ok(StreamToFileOutcome::StoppedUnexpectedly);
}
}
}
if n > payload_length_limit {
return Ok(StreamToFileOutcome::TooMuchContent);
}
Ok(StreamToFileOutcome::Success)
}
Err(error) => match error.kind() {
ErrorKind::TimedOut => Ok(StreamToFileOutcome::StoppedUnexpectedly),
ErrorKind::BrokenPipe => Ok(StreamToFileOutcome::StoppedUnexpectedly),
ErrorKind::ConnectionReset => Ok(StreamToFileOutcome::StoppedUnexpectedly),
_ => Err(error),
},
}
}