1use crate::{
2 HttpBody, WireRequest, WireResponse,
3 adapt::JsonInvocation,
4 capability::Upstream,
5 codec::{self, CodecErrorKind, CodecLimits, MultipartEncoder},
6 connection::MultipartPart,
7 transform::{TransformError, TransformErrorKind},
8 wire::{DeclaredFields, gemini::files as g},
9};
10use serde::de::DeserializeOwned;
11
12#[derive(Debug)]
13pub enum UploadFailure {
14 Transform(TransformError),
15 Rejected(Box<WireResponse<HttpBody>>),
16}
17
18#[derive(Debug)]
21pub struct UploadError {
22 pub attempted_calls: usize,
23 pub failure: UploadFailure,
24}
25
26impl From<TransformError> for UploadError {
27 fn from(error: TransformError) -> Self {
28 Self {
29 attempted_calls: 0,
30 failure: UploadFailure::Transform(error),
31 }
32 }
33}
34
35fn after(error: TransformError) -> UploadError {
36 UploadError {
37 attempted_calls: 1,
38 failure: UploadFailure::Transform(error),
39 }
40}
41
42fn codec_error(error: codec::CodecError, response: bool) -> TransformError {
43 let kind = match error.kind() {
44 CodecErrorKind::Limit => TransformErrorKind::Limit,
45 CodecErrorKind::Transport => TransformErrorKind::Host,
46 CodecErrorKind::Invalid
47 | CodecErrorKind::UnexpectedEof
48 | CodecErrorKind::Utf8
49 | CodecErrorKind::Json
50 | CodecErrorKind::Multipart => {
51 if response {
52 TransformErrorKind::InvalidResult
53 } else {
54 TransformErrorKind::InvalidInput
55 }
56 }
57 };
58 TransformError::with_source(kind, "file.upload", error.to_string(), error)
59}
60
61fn bounded(mut limits: CodecLimits, bytes: u64) -> CodecLimits {
62 limits.max_body_bytes = limits.max_body_bytes.min(bytes);
63 limits.max_value_bytes = limits.max_value_bytes.min(bytes);
64 limits.max_buffer_bytes = limits.max_buffer_bytes.min(bytes);
65 limits
66}
67
68fn clean(headers: &mut http::HeaderMap) {
69 for key in [
70 http::header::CONTENT_LENGTH,
71 http::header::CONTENT_ENCODING,
72 http::header::TRANSFER_ENCODING,
73 ] {
74 headers.remove(key);
75 }
76}
77
78async fn decode<O: DeserializeOwned + DeclaredFields>(
79 response: WireResponse<HttpBody>,
80 limits: CodecLimits,
81) -> Result<JsonInvocation<O>, TransformError> {
82 if !response.status.is_success() {
83 return Ok(JsonInvocation::Rejected(response));
84 }
85 let WireResponse {
86 status,
87 mut headers,
88 body,
89 } = response;
90 let bytes = codec::read_http_body(body, limits)
91 .await
92 .map_err(|e| codec_error(e, true))?;
93 let body: O = codec::decode_json(&bytes, limits).map_err(|e| codec_error(e, true))?;
94 clean(&mut headers);
95 headers.insert(
96 http::header::CONTENT_TYPE,
97 http::HeaderValue::from_static("application/json"),
98 );
99 Ok(JsonInvocation::Success(WireResponse {
100 status,
101 headers,
102 body: body.into_declared(),
103 }))
104}
105
106pub async fn upload_multipart_json<U: Upstream, O: DeserializeOwned + DeclaredFields>(
107 upstream: &U,
108 target: &U::Target,
109 mut template: WireRequest<()>,
110 boundary: String,
111 parts: Vec<MultipartPart>,
112 limits: CodecLimits,
113) -> Result<JsonInvocation<O>, UploadError> {
114 super::path(&template.path)?;
115 if template.method != http::Method::POST {
116 return Err(TransformError::shape("upload.method", "POST required").into());
117 }
118 let host = upstream.limits();
119 let encoder = MultipartEncoder::new(boundary.clone(), parts, bounded(limits, host.write_bytes))
120 .map_err(|e| codec_error(e, false))?;
121 let body = futures_util::stream::try_unfold(encoder, |mut encoder| async move {
122 encoder
123 .next_chunk()
124 .await
125 .map(|chunk| chunk.map(|chunk| (chunk, encoder)))
126 .map_err(crate::connection::TransportError::from)
127 });
128 clean(&mut template.headers);
129 template.headers.insert(
130 http::header::CONTENT_TYPE,
131 http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
132 .map_err(|e| TransformError::shape("content_type", e.to_string()))?,
133 );
134 let response = upstream
135 .send(
136 target,
137 WireRequest {
138 method: template.method,
139 path: template.path,
140 query: template.query,
141 headers: template.headers,
142 body: HttpBody::Stream(Box::pin(body)),
143 },
144 )
145 .await
146 .map_err(|e| after(e.into()))?;
147 decode(response, bounded(limits, host.read_bytes))
148 .await
149 .map_err(after)
150}
151
152#[derive(Debug, Clone)]
153pub struct GeminiUploadSession {
154 upload_path: String,
155 query: Option<String>,
156 origin: String,
157 offset: u64,
158 size: u64,
159 closed: bool,
160}
161
162impl GeminiUploadSession {
163 pub fn offset(&self) -> u64 {
164 self.offset
165 }
166 pub fn size(&self) -> u64 {
167 self.size
168 }
169 pub fn origin(&self) -> &str {
170 &self.origin
171 }
172 pub fn is_closed(&self) -> bool {
173 self.closed
174 }
175}
176
177fn origin(value: &str) -> Result<String, TransformError> {
178 let uri: http::Uri = value.parse().map_err(|e: http::uri::InvalidUri| {
179 TransformError::shape("upload.origin", e.to_string())
180 })?;
181 if uri.scheme_str() != Some("https")
182 || uri.authority().is_none()
183 || uri.authority().unwrap().as_str().contains('@')
184 || !matches!(uri.path(), "" | "/")
185 || uri.query().is_some()
186 {
187 return Err(TransformError::shape(
188 "upload.origin",
189 "HTTPS origin without userinfo/path/query required",
190 ));
191 }
192 Ok(format!(
193 "https://{}",
194 uri.authority().unwrap().as_str().to_ascii_lowercase()
195 ))
196}
197
198#[allow(clippy::too_many_arguments)] pub async fn gemini_start_resumable<U: Upstream>(
200 upstream: &U,
201 target: &U::Target,
202 mut template: WireRequest<()>,
203 metadata: g::UploadFileMetadata,
204 size: u64,
205 mime: &str,
206 expected_origin: &str,
207 limits: CodecLimits,
208) -> Result<GeminiUploadSession, UploadError> {
209 let expected_origin = origin(expected_origin)?;
210 super::path(&template.path)?;
211 if template.method != http::Method::POST || mime.is_empty() {
212 return Err(TransformError::shape("upload.start", "POST and MIME required").into());
213 }
214 let body = codec::encode_json(
215 &metadata.into_declared(),
216 bounded(limits, upstream.limits().write_bytes),
217 )
218 .map_err(|e| codec_error(e, false))?;
219 clean(&mut template.headers);
220 for (name, value) in [
221 ("x-goog-upload-protocol", "resumable".to_owned()),
222 ("x-goog-upload-command", "start".to_owned()),
223 ("x-goog-upload-header-content-length", size.to_string()),
224 ("x-goog-upload-header-content-type", mime.to_owned()),
225 ("content-type", "application/json".to_owned()),
226 ] {
227 template.headers.insert(
228 http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
229 http::HeaderValue::from_str(&value)
230 .map_err(|e| TransformError::shape("upload.headers", e.to_string()))?,
231 );
232 }
233 let response = upstream
234 .send(
235 target,
236 WireRequest {
237 method: template.method,
238 path: template.path,
239 query: template.query,
240 headers: template.headers,
241 body: HttpBody::Bytes(body),
242 },
243 )
244 .await
245 .map_err(|e| after(e.into()))?;
246 if !response.status.is_success() {
247 return Err(UploadError {
248 attempted_calls: 1,
249 failure: UploadFailure::Rejected(Box::new(response)),
250 });
251 }
252 let url = response
253 .headers
254 .get("x-goog-upload-url")
255 .ok_or_else(|| after(TransformError::missing_metadata("x-goog-upload-url")))?
256 .to_str()
257 .map_err(|e| after(TransformError::invalid_result("upload_url", e.to_string())))?;
258 let parsed: http::Uri = url.parse().map_err(|e: http::uri::InvalidUri| {
259 after(TransformError::invalid_result("upload_url", e.to_string()))
260 })?;
261 let received = format!(
262 "{}://{}",
263 parsed.scheme_str().unwrap_or(""),
264 parsed.authority().map(|a| a.as_str()).unwrap_or("")
265 );
266 if origin(&received).map_err(after)? != expected_origin {
267 return Err(after(TransformError::invalid_result(
268 "upload_url",
269 "session origin differs from selected target",
270 )));
271 }
272 super::path(parsed.path()).map_err(after)?;
273 Ok(GeminiUploadSession {
274 upload_path: parsed.path().into(),
275 query: parsed.query().map(str::to_owned),
276 origin: expected_origin,
277 offset: 0,
278 size,
279 closed: false,
280 })
281}
282
283#[derive(Debug)]
284pub enum GeminiUploadProgress {
285 Accepted(http::StatusCode),
286 Finalized(Box<WireResponse<g::UploadFileResponseBody>>),
287}
288
289#[allow(clippy::too_many_arguments)] pub async fn gemini_upload_chunk<U: Upstream>(
291 upstream: &U,
292 target: &U::Target,
293 session: &mut GeminiUploadSession,
294 headers: http::HeaderMap,
295 expected_origin: &str,
296 chunk: bytes::Bytes,
297 finalize: bool,
298 limits: CodecLimits,
299) -> Result<GeminiUploadProgress, UploadError> {
300 if session.closed {
301 return Err(TransformError::shape(
302 "upload.session",
303 "session is closed or outcome uncertain",
304 )
305 .into());
306 }
307 if origin(expected_origin)? != session.origin {
308 return Err(TransformError::shape(
309 "upload.origin",
310 "selected target origin differs from session",
311 )
312 .into());
313 }
314 let next = session
315 .offset
316 .checked_add(chunk.len() as u64)
317 .ok_or_else(|| {
318 TransformError::new(TransformErrorKind::Limit, "upload.offset", "overflow")
319 })?;
320 if next > session.size || finalize && next != session.size {
321 return Err(TransformError::shape(
322 "upload.size",
323 "chunk/finalize does not match declared size",
324 )
325 .into());
326 }
327 if chunk.len() as u64 > upstream.limits().write_bytes.min(limits.max_body_bytes) {
328 return Err(TransformError::new(
329 TransformErrorKind::Limit,
330 "upload.chunk",
331 "chunk exceeds write limit",
332 )
333 .into());
334 }
335 let mut headers = headers;
336 clean(&mut headers);
337 headers.insert(
338 "x-goog-upload-command",
339 http::HeaderValue::from_static(if finalize {
340 "upload, finalize"
341 } else {
342 "upload"
343 }),
344 );
345 headers.insert(
346 "x-goog-upload-offset",
347 http::HeaderValue::from_str(&session.offset.to_string()).unwrap(),
348 );
349 headers.insert(
350 http::header::CONTENT_LENGTH,
351 http::HeaderValue::from_str(&chunk.len().to_string()).unwrap(),
352 );
353 session.closed = true;
356 let response = upstream
357 .send(
358 target,
359 WireRequest {
360 method: http::Method::POST,
361 path: session.upload_path.clone(),
362 query: session.query.clone(),
363 headers,
364 body: HttpBody::Bytes(chunk),
365 },
366 )
367 .await
368 .map_err(|e| after(e.into()))?;
369 if !response.status.is_success() {
370 return Err(UploadError {
371 attempted_calls: 1,
372 failure: UploadFailure::Rejected(Box::new(response)),
373 });
374 }
375 if finalize {
376 match decode(response, bounded(limits, upstream.limits().read_bytes))
377 .await
378 .map_err(after)?
379 {
380 JsonInvocation::Success(response) => {
381 session.offset = next;
382 Ok(GeminiUploadProgress::Finalized(Box::new(response)))
383 }
384 JsonInvocation::Rejected(_) => unreachable!("checked status"),
385 }
386 } else {
387 let status = response.status;
388 codec::read_http_body(response.body, bounded(limits, upstream.limits().read_bytes))
389 .await
390 .map_err(|e| after(codec_error(e, true)))?;
391 session.offset = next;
392 session.closed = false;
393 Ok(GeminiUploadProgress::Accepted(status))
394 }
395}