use std::fs::File;
use std::io::{Cursor, Read};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::client::Input;
use crate::errors::{Api2ConvertError, Result};
use crate::models::{InputFile, Job};
use crate::transport::{encode_segment, HttpRequest, Transport};
enum UploadSource {
Path(std::path::PathBuf),
Bytes(Arc<Vec<u8>>),
Reader(Mutex<Option<Box<dyn Read + Send>>>),
}
pub(crate) fn upload(
transport: &Transport,
job: &Job,
file: Input,
filename: Option<&str>,
) -> Result<InputFile> {
let server = job
.server
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
Api2ConvertError::Config(
"cannot upload: the job has no upload server (create it with process:false first)"
.to_string(),
)
})?;
let token = job
.token
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
Api2ConvertError::Config("cannot upload: the job has no upload token".to_string())
})?;
let (default_name, source, replayable): (String, UploadSource, bool) = match file {
Input::Url(_) => {
return Err(Api2ConvertError::Config(
"cannot upload a URL; add it as a remote input instead".to_string(),
))
}
Input::Path(path) => {
if !path.exists() {
return Err(Api2ConvertError::Config(format!(
"file not found: {}",
path.display()
)));
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
(name, UploadSource::Path(path), true)
}
Input::Bytes(bytes) => (
"file".to_string(),
UploadSource::Bytes(Arc::new(bytes)),
true,
),
Input::Reader(reader) => (
"file".to_string(),
UploadSource::Reader(Mutex::new(Some(reader))),
false,
),
};
let fname = sanitize_filename(filename.unwrap_or(&default_name));
let boundary = make_boundary();
let preamble = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{fname}\"\r\n\
Content-Type: application/octet-stream\r\n\r\n"
)
.into_bytes();
let epilogue = format!("\r\n--{boundary}--\r\n").into_bytes();
let content_type = format!("multipart/form-data; boundary={boundary}");
let source = Arc::new(source);
let make_body = {
let source = Arc::clone(&source);
let preamble = preamble.clone();
let epilogue = epilogue.clone();
Box::new(move || -> std::io::Result<Box<dyn Read + Send>> {
let inner: Box<dyn Read + Send> = match &*source {
UploadSource::Path(p) => Box::new(File::open(p)?),
UploadSource::Bytes(b) => Box::new(Cursor::new((**b).clone())),
UploadSource::Reader(cell) => match cell.lock().unwrap().take() {
Some(r) => r,
None => {
return Err(std::io::Error::other("upload stream already consumed"));
}
},
};
let chained = Cursor::new(preamble.clone())
.chain(inner)
.chain(Cursor::new(epilogue.clone()));
Ok(Box::new(chained) as Box<dyn Read + Send>)
})
};
let url = format!(
"{}/upload-file/{}",
server.trim_end_matches('/'),
encode_segment(&job.id)
);
let headers = vec![
("X-Api2convert-Token".to_string(), token.to_string()),
("Content-Type".to_string(), content_type),
(
"User-Agent".to_string(),
format!(
"api2convert-rust/{} ({})",
crate::VERSION,
std::env::consts::OS
),
),
];
let req = HttpRequest {
method: "POST".to_string(),
url,
headers,
body: None,
make_body: Some(make_body),
follow_redirects: false,
replayable,
timeout: None,
};
let v = transport.send(req)?;
Ok(InputFile::from_value(&v))
}
fn sanitize_filename(name: &str) -> String {
let cleaned: String = name
.chars()
.filter(|&c| c != '\r' && c != '\n' && c != '"' && c != '\0')
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"file".to_string()
} else {
trimmed.to_string()
}
}
fn make_boundary() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
format!("----api2convert{t:016x}{n:016x}")
}