1use std::{path::Path, time::Duration};
2
3use bytes::Bytes;
4use futures_core::Stream;
5use reqwest::{Body, Client, ClientBuilder, Url, multipart::Form};
6use serde::Serialize;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use tokio::fs::File;
10use tokio::io::AsyncReadExt;
11use tokio_util::io::ReaderStream;
12use uuid::Uuid;
13
14use crate::models::{
15 AlbumFilesPage, AlbumFilesResponse, AlbumsResponse, CreateAlbumRequest, CreateAlbumResponse,
16 EditAlbumRequest, EditAlbumResponse, LoginRequest, LoginResponse, NodeResponse,
17 RegisterRequest, RegisterResponse, UploadProgress, UploadResponse, VerifyTokenRequest,
18 VerifyTokenResponse,
19};
20use crate::transport::Transport;
21use crate::{
22 AlbumsList, AuthToken, CyberdropError, EditAlbumResult, TokenVerification, UploadedFile,
23};
24
25#[derive(Debug, Clone)]
26pub(crate) struct ChunkFields {
27 pub(crate) uuid: String,
28 pub(crate) chunk_index: u64,
29 pub(crate) total_size: u64,
30 pub(crate) chunk_size: u64,
31 pub(crate) total_chunks: u64,
32 pub(crate) byte_offset: u64,
33 pub(crate) file_name: String,
34 pub(crate) mime_type: String,
35 pub(crate) album_id: Option<u64>,
36}
37
38#[derive(Debug, Serialize)]
39pub(crate) struct FinishFile {
40 pub(crate) uuid: String,
41 pub(crate) original: String,
42 #[serde(rename = "type")]
43 pub(crate) r#type: String,
44 pub(crate) albumid: Option<u64>,
45 pub(crate) filelength: Option<u64>,
46 pub(crate) age: Option<u64>,
47}
48
49#[derive(Debug, Serialize)]
50pub(crate) struct FinishChunksPayload {
51 pub(crate) files: Vec<FinishFile>,
52}
53
54struct ProgressStream<S, F> {
55 inner: S,
56 bytes_sent: u64,
57 total_bytes: u64,
58 file_name: String,
59 callback: F,
60}
61
62impl<S, F> ProgressStream<S, F>
63where
64 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
65 F: FnMut(UploadProgress) + Send,
66{
67 fn new(inner: S, total_bytes: u64, file_name: String, callback: F) -> Self {
68 Self {
69 inner,
70 bytes_sent: 0,
71 total_bytes,
72 file_name,
73 callback,
74 }
75 }
76}
77
78impl<S, F> Stream for ProgressStream<S, F>
79where
80 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
81 F: FnMut(UploadProgress) + Send,
82{
83 type Item = Result<Bytes, std::io::Error>;
84
85 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
86 let this = self.get_mut();
87 match Pin::new(&mut this.inner).poll_next(cx) {
88 Poll::Ready(Some(Ok(bytes))) => {
89 this.bytes_sent = this.bytes_sent.saturating_add(bytes.len() as u64);
90 (this.callback)(UploadProgress {
91 file_name: this.file_name.clone(),
92 bytes_sent: this.bytes_sent,
93 total_bytes: this.total_bytes,
94 });
95 Poll::Ready(Some(Ok(bytes)))
96 }
97 other => other,
98 }
99 }
100}
101
102impl<S, F> Unpin for ProgressStream<S, F>
103where
104 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
105 F: FnMut(UploadProgress) + Send,
106{
107}
108
109struct PreparedUpload {
110 file: File,
111 file_name: String,
112 mime: String,
113 total_size: u64,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum UploadStrategy {
118 Single,
119 Chunked,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123struct ChunkUploadPlan {
124 chunk_size: u64,
125 total_chunks: u64,
126}
127
128impl ChunkUploadPlan {
129 fn new(total_size: u64) -> Self {
130 let chunk_size = CHUNK_SIZE.min(total_size.max(1));
131 let total_chunks = total_size.div_ceil(chunk_size).max(1);
132
133 Self {
134 chunk_size,
135 total_chunks,
136 }
137 }
138
139 fn byte_offset(self, chunk_index: u64) -> u64 {
140 chunk_index * self.chunk_size
141 }
142}
143
144async fn prepare_upload_file(file_path: &Path) -> Result<PreparedUpload, CyberdropError> {
145 let file_name = file_path
146 .file_name()
147 .and_then(|n| n.to_str())
148 .ok_or(CyberdropError::InvalidFileName)?
149 .to_string();
150
151 let mime = mime_guess::from_path(file_path)
152 .first_raw()
153 .unwrap_or("application/octet-stream")
154 .to_string();
155
156 let file = File::open(file_path).await?;
157 let total_size = file.metadata().await?.len();
158
159 Ok(PreparedUpload {
160 file,
161 file_name,
162 mime,
163 total_size,
164 })
165}
166
167fn select_upload_strategy(total_size: u64) -> UploadStrategy {
168 if total_size <= CHUNK_SIZE {
169 UploadStrategy::Single
170 } else {
171 UploadStrategy::Chunked
172 }
173}
174
175fn build_chunk_fields(
176 uuid: &str,
177 chunk_index: u64,
178 plan: ChunkUploadPlan,
179 total_size: u64,
180 file_name: &str,
181 mime_type: &str,
182 album_id: Option<u64>,
183) -> ChunkFields {
184 ChunkFields {
185 uuid: uuid.to_string(),
186 chunk_index,
187 total_size,
188 chunk_size: plan.chunk_size,
189 total_chunks: plan.total_chunks,
190 byte_offset: plan.byte_offset(chunk_index),
191 file_name: file_name.to_string(),
192 mime_type: mime_type.to_string(),
193 album_id,
194 }
195}
196
197fn build_finish_chunks_payload(
198 uuid: String,
199 file_name: String,
200 mime: String,
201 album_id: Option<u64>,
202) -> FinishChunksPayload {
203 FinishChunksPayload {
204 files: vec![FinishFile {
205 uuid,
206 original: file_name,
207 r#type: mime,
208 albumid: album_id,
209 filelength: None,
210 age: None,
211 }],
212 }
213}
214
215fn finish_chunks_url(mut upload_url: Url) -> Url {
216 upload_url.set_path("/api/upload/finishchunks");
217 upload_url
218}
219
220#[derive(Debug, Clone)]
225pub struct CyberdropClient {
226 transport: Transport,
227}
228
229#[derive(Debug)]
231pub struct CyberdropClientBuilder {
232 base_url: Option<Url>,
233 user_agent: Option<String>,
234 timeout: Duration,
235 auth_token: Option<AuthToken>,
236 builder: ClientBuilder,
237}
238
239const CHUNK_SIZE: u64 = 95_000_000;
240const DEFAULT_BASE_URL: &str = "https://cyberdrop.cr/";
241const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
242
243impl CyberdropClient {
244 pub fn new(base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
249 CyberdropClientBuilder::new().base_url(base_url)?.build()
250 }
251
252 pub fn builder() -> CyberdropClientBuilder {
259 CyberdropClientBuilder::new()
260 }
261
262 pub fn base_url(&self) -> &Url {
264 self.transport.base_url()
265 }
266
267 pub fn auth_token(&self) -> Option<&str> {
269 self.transport.auth_token()
270 }
271
272 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
276 self.transport = self.transport.with_auth_token(token);
277 self
278 }
279
280 pub async fn get_album_by_id(
281 &self,
282 album_id: u64,
283 ) -> Result<crate::models::Album, CyberdropError> {
284 let albums = self.list_albums().await?;
285 albums
286 .albums
287 .into_iter()
288 .find(|album| album.id == album_id)
289 .ok_or(CyberdropError::AlbumNotFound(album_id))
290 }
291
292 pub async fn get(&self, path: impl AsRef<str>) -> Result<reqwest::Response, CyberdropError> {
303 self.transport.get_raw(path.as_ref()).await
304 }
305
306 pub async fn login(
317 &self,
318 username: impl Into<String>,
319 password: impl Into<String>,
320 ) -> Result<AuthToken, CyberdropError> {
321 let payload = LoginRequest {
322 username: username.into(),
323 password: password.into(),
324 };
325
326 let response: LoginResponse = self
327 .transport
328 .post_json("api/login", &payload, false)
329 .await?;
330
331 AuthToken::try_from(response)
332 }
333
334 pub async fn register(
348 &self,
349 username: impl Into<String>,
350 password: impl Into<String>,
351 ) -> Result<AuthToken, CyberdropError> {
352 let payload = RegisterRequest {
353 username: username.into(),
354 password: password.into(),
355 };
356
357 let response: RegisterResponse = self
358 .transport
359 .post_json("api/register", &payload, false)
360 .await?;
361
362 AuthToken::try_from(response)
363 }
364
365 pub async fn verify_token(
376 &self,
377 token: impl Into<String>,
378 ) -> Result<TokenVerification, CyberdropError> {
379 let payload = VerifyTokenRequest {
380 token: token.into(),
381 };
382
383 let response: VerifyTokenResponse = self
384 .transport
385 .post_json("api/tokens/verify", &payload, false)
386 .await?;
387
388 TokenVerification::try_from(response)
389 }
390
391 pub async fn get_upload_url(&self) -> Result<Url, CyberdropError> {
395 let response: NodeResponse = self.transport.get_json("api/node", true).await?;
396
397 if !response.success.unwrap_or(false) {
398 let msg = response
399 .description
400 .or(response.message)
401 .unwrap_or_else(|| "failed to fetch upload node".to_string());
402 return Err(CyberdropError::Api(msg));
403 }
404
405 let url = response
406 .url
407 .ok_or(CyberdropError::MissingField("node response missing url"))?;
408
409 Ok(Url::parse(&url)?)
410 }
411
412 pub async fn list_albums(&self) -> Result<AlbumsList, CyberdropError> {
423 let response: AlbumsResponse = self
424 .transport
425 .get_json_with_header("api/albums", true, "Simple", "1")
426 .await?;
427 AlbumsList::try_from(response)
428 }
429
430 pub async fn list_album_files(&self, album_id: u64) -> Result<AlbumFilesPage, CyberdropError> {
449 let mut page = 0u64;
450 let mut all_files = Vec::new();
451 let mut total_count = None::<u64>;
452 let mut albums = std::collections::HashMap::new();
453 let mut base_domain = None::<Url>;
454 let mut seen = std::collections::HashSet::<u64>::new();
455
456 loop {
457 let res = self.list_album_files_page(album_id, page).await?;
458
459 if base_domain.is_none() {
460 base_domain = res.base_domain.clone();
461 }
462 if total_count.is_none() {
463 total_count = Some(res.count);
464 }
465 albums.extend(res.albums.into_iter());
466
467 if res.files.is_empty() {
468 break;
469 }
470
471 let mut added = 0usize;
472 for file in res.files.into_iter() {
473 if seen.insert(file.id) {
474 all_files.push(file);
475 added += 1;
476 }
477 }
478
479 if added == 0 {
480 break;
481 }
482
483 if let Some(total) = total_count
484 && all_files.len() as u64 >= total
485 {
486 break;
487 }
488
489 page += 1;
490 }
491
492 Ok(AlbumFilesPage {
493 success: true,
494 files: all_files,
495 count: total_count.unwrap_or(0),
496 albums,
497 base_domain,
498 })
499 }
500
501 pub async fn list_album_files_page(
516 &self,
517 album_id: u64,
518 page: u64,
519 ) -> Result<AlbumFilesPage, CyberdropError> {
520 let path = format!("api/album/{album_id}/{page}");
521 let response: AlbumFilesResponse = self.transport.get_json(&path, true).await?;
522 AlbumFilesPage::try_from(response)
523 }
524
525 pub async fn create_album(
539 &self,
540 name: impl Into<String>,
541 description: Option<impl Into<String>>,
542 ) -> Result<u64, CyberdropError> {
543 let payload = CreateAlbumRequest {
544 name: name.into(),
545 description: description.map(Into::into),
546 };
547
548 let response: CreateAlbumResponse = self
549 .transport
550 .post_json("api/albums", &payload, true)
551 .await?;
552
553 u64::try_from(response)
554 }
555
556 pub async fn edit_album(
576 &self,
577 id: u64,
578 name: impl Into<String>,
579 description: impl Into<String>,
580 download: bool,
581 public: bool,
582 request_new_link: bool,
583 ) -> Result<EditAlbumResult, CyberdropError> {
584 let payload = EditAlbumRequest {
585 id,
586 name: name.into(),
587 description: description.into(),
588 download,
589 public,
590 request_link: request_new_link,
591 };
592
593 let response: EditAlbumResponse = self
594 .transport
595 .post_json("api/albums/edit", &payload, true)
596 .await?;
597
598 EditAlbumResult::try_from(response)
599 }
600
601 pub async fn request_new_album_link(&self, album_id: u64) -> Result<String, CyberdropError> {
625 let album = self.get_album_by_id(album_id).await?;
626
627 let edited = self
628 .edit_album(
629 album_id,
630 album.name,
631 album.description,
632 album.download,
633 album.public,
634 true,
635 )
636 .await?;
637
638 let identifier = edited.identifier.ok_or(CyberdropError::MissingField(
639 "edit album response missing identifier",
640 ))?;
641
642 let identifier = identifier.trim_start_matches('/');
643 Ok(identifier.to_string())
644 }
645
646 pub async fn set_album_name(
667 &self,
668 album_id: u64,
669 name: impl Into<String>,
670 ) -> Result<EditAlbumResult, CyberdropError> {
671 let album = self.get_album_by_id(album_id).await?;
672 self.edit_album(
673 album_id,
674 name,
675 album.description,
676 album.download,
677 album.public,
678 false,
679 )
680 .await
681 }
682
683 pub async fn set_album_description(
704 &self,
705 album_id: u64,
706 description: impl Into<String>,
707 ) -> Result<EditAlbumResult, CyberdropError> {
708 let album = self.get_album_by_id(album_id).await?;
709 self.edit_album(
710 album_id,
711 album.name,
712 description,
713 album.download,
714 album.public,
715 false,
716 )
717 .await
718 }
719
720 pub async fn set_album_download(
741 &self,
742 album_id: u64,
743 download: bool,
744 ) -> Result<EditAlbumResult, CyberdropError> {
745 let album = self.get_album_by_id(album_id).await?;
746 self.edit_album(
747 album_id,
748 album.name,
749 album.description,
750 download,
751 album.public,
752 false,
753 )
754 .await
755 }
756
757 pub async fn set_album_public(
778 &self,
779 album_id: u64,
780 public: bool,
781 ) -> Result<EditAlbumResult, CyberdropError> {
782 let album = self.get_album_by_id(album_id).await?;
783 self.edit_album(
784 album_id,
785 album.name,
786 album.description,
787 album.download,
788 public,
789 false,
790 )
791 .await
792 }
793
794 pub async fn upload_file(
815 &self,
816 file_path: impl AsRef<Path>,
817 album_id: Option<u64>,
818 ) -> Result<UploadedFile, CyberdropError> {
819 self.upload_file_with_progress(file_path, album_id, |_| {})
820 .await
821 }
822
823 pub async fn upload_file_with_progress<F>(
827 &self,
828 file_path: impl AsRef<Path>,
829 album_id: Option<u64>,
830 on_progress: F,
831 ) -> Result<UploadedFile, CyberdropError>
832 where
833 F: FnMut(UploadProgress) + Send + 'static,
834 {
835 let prepared = prepare_upload_file(file_path.as_ref()).await?;
836 let upload_url = self.get_upload_url().await?;
837
838 match select_upload_strategy(prepared.total_size) {
839 UploadStrategy::Single => {
840 self.upload_small_file_with_progress(upload_url, prepared, album_id, on_progress)
841 .await
842 }
843 UploadStrategy::Chunked => {
844 self.upload_chunked_file_with_progress(upload_url, prepared, album_id, on_progress)
845 .await
846 }
847 }
848 }
849
850 async fn upload_small_file_with_progress<F>(
851 &self,
852 upload_url: Url,
853 prepared: PreparedUpload,
854 album_id: Option<u64>,
855 on_progress: F,
856 ) -> Result<UploadedFile, CyberdropError>
857 where
858 F: FnMut(UploadProgress) + Send + 'static,
859 {
860 let PreparedUpload {
861 file,
862 file_name,
863 mime,
864 total_size,
865 } = prepared;
866
867 let stream = ReaderStream::new(file);
868 let progress_stream =
869 ProgressStream::new(stream, total_size, file_name.clone(), on_progress);
870 let body = Body::wrap_stream(progress_stream);
871 let part = reqwest::multipart::Part::stream_with_length(body, total_size)
872 .file_name(file_name.clone());
873 let part = match part.mime_str(&mime) {
874 Ok(p) => p,
875 Err(_) => reqwest::multipart::Part::bytes(Vec::new()).file_name(file_name.clone()),
876 };
877 let form = Form::new().part("files[]", part);
878 let response: UploadResponse = self
879 .transport
880 .post_single_upload_url(upload_url, form, album_id)
881 .await?;
882
883 UploadedFile::try_from(response)
884 }
885
886 async fn upload_chunked_file_with_progress<F>(
887 &self,
888 upload_url: Url,
889 prepared: PreparedUpload,
890 album_id: Option<u64>,
891 mut on_progress: F,
892 ) -> Result<UploadedFile, CyberdropError>
893 where
894 F: FnMut(UploadProgress) + Send + 'static,
895 {
896 let PreparedUpload {
897 mut file,
898 file_name,
899 mime,
900 total_size,
901 } = prepared;
902
903 let plan = ChunkUploadPlan::new(total_size);
904 let uuid = Uuid::new_v4().to_string();
905 let mut bytes_sent = 0u64;
906 let mut chunk_index = 0u64;
907 let mut buffer = Vec::with_capacity(plan.chunk_size as usize);
908
909 loop {
910 buffer.clear();
911 let read = file.read_buf(&mut buffer).await?;
912 if read == 0 {
913 break;
914 }
915
916 let response: serde_json::Value = self
917 .transport
918 .post_chunk_url(
919 upload_url.clone(),
920 buffer,
921 build_chunk_fields(
922 &uuid,
923 chunk_index,
924 plan,
925 total_size,
926 &file_name,
927 &mime,
928 album_id,
929 ),
930 )
931 .await?;
932
933 if !response
934 .get("success")
935 .and_then(|v| v.as_bool())
936 .unwrap_or(false)
937 {
938 return Err(CyberdropError::Api(format!("chunk {} failed", chunk_index)));
939 }
940
941 bytes_sent = bytes_sent.saturating_add(read as u64);
942 on_progress(UploadProgress {
943 file_name: file_name.clone(),
944 bytes_sent,
945 total_bytes: total_size,
946 });
947 chunk_index = chunk_index.saturating_add(1);
948 buffer = Vec::with_capacity(plan.chunk_size as usize);
949 }
950
951 self.finish_chunked_upload(upload_url, uuid, file_name, mime, album_id)
952 .await
953 }
954
955 async fn finish_chunked_upload(
956 &self,
957 upload_url: Url,
958 uuid: String,
959 file_name: String,
960 mime: String,
961 album_id: Option<u64>,
962 ) -> Result<UploadedFile, CyberdropError> {
963 let payload = build_finish_chunks_payload(uuid, file_name, mime, album_id);
964 let finish_url = finish_chunks_url(upload_url);
965
966 let response: UploadResponse = self
967 .transport
968 .post_json_with_upload_headers_url(finish_url, &payload)
969 .await?;
970
971 UploadedFile::try_from(response)
972 }
973}
974
975impl CyberdropClientBuilder {
976 pub fn new() -> Self {
980 Self {
981 base_url: None,
982 user_agent: None,
983 timeout: DEFAULT_TIMEOUT,
984 auth_token: None,
985 builder: Client::builder(),
986 }
987 }
988
989 pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
991 self.base_url = Some(Url::parse(base_url.as_ref())?);
992 Ok(self)
993 }
994
995 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
997 self.user_agent = Some(user_agent.into());
998 self
999 }
1000
1001 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
1003 self.auth_token = Some(AuthToken::new(token));
1004 self
1005 }
1006
1007 pub fn timeout(mut self, timeout: Duration) -> Self {
1012 self.timeout = timeout;
1013 self
1014 }
1015
1016 pub fn build(self) -> Result<CyberdropClient, CyberdropError> {
1021 let base_url = match self.base_url {
1022 Some(url) => url,
1023 None => Url::parse(DEFAULT_BASE_URL)?,
1024 };
1025
1026 let mut builder = self.builder.timeout(self.timeout);
1027 builder = builder.user_agent(self.user_agent.unwrap_or_else(default_user_agent));
1028
1029 let client = builder.build()?;
1030
1031 Ok(CyberdropClient {
1032 transport: Transport::new(client, base_url, self.auth_token),
1033 })
1034 }
1035}
1036
1037impl Default for CyberdropClientBuilder {
1038 fn default() -> Self {
1039 Self::new()
1040 }
1041}
1042
1043fn default_user_agent() -> String {
1044 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0".into()
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 #[test]
1053 fn select_upload_strategy_uses_chunk_threshold() {
1054 assert_eq!(select_upload_strategy(0), UploadStrategy::Single);
1055 assert_eq!(select_upload_strategy(CHUNK_SIZE), UploadStrategy::Single);
1056 assert_eq!(
1057 select_upload_strategy(CHUNK_SIZE + 1),
1058 UploadStrategy::Chunked
1059 );
1060 }
1061
1062 #[test]
1063 fn chunk_upload_plan_calculates_chunk_boundaries() {
1064 let one_chunk = ChunkUploadPlan::new(CHUNK_SIZE);
1065 assert_eq!(one_chunk.chunk_size, CHUNK_SIZE);
1066 assert_eq!(one_chunk.total_chunks, 1);
1067 assert_eq!(one_chunk.byte_offset(0), 0);
1068
1069 let partial_second_chunk = ChunkUploadPlan::new(CHUNK_SIZE + 1);
1070 assert_eq!(partial_second_chunk.chunk_size, CHUNK_SIZE);
1071 assert_eq!(partial_second_chunk.total_chunks, 2);
1072 assert_eq!(partial_second_chunk.byte_offset(1), CHUNK_SIZE);
1073
1074 let empty_file_plan = ChunkUploadPlan::new(0);
1075 assert_eq!(empty_file_plan.chunk_size, 1);
1076 assert_eq!(empty_file_plan.total_chunks, 1);
1077 }
1078
1079 #[test]
1080 fn build_chunk_fields_maps_plan_and_metadata() {
1081 let plan = ChunkUploadPlan::new(CHUNK_SIZE + 1);
1082 let fields = build_chunk_fields(
1083 "upload-id",
1084 1,
1085 plan,
1086 CHUNK_SIZE + 1,
1087 "image.jpg",
1088 "image/jpeg",
1089 Some(42),
1090 );
1091
1092 assert_eq!(fields.uuid, "upload-id");
1093 assert_eq!(fields.chunk_index, 1);
1094 assert_eq!(fields.total_size, CHUNK_SIZE + 1);
1095 assert_eq!(fields.chunk_size, CHUNK_SIZE);
1096 assert_eq!(fields.total_chunks, 2);
1097 assert_eq!(fields.byte_offset, CHUNK_SIZE);
1098 assert_eq!(fields.file_name, "image.jpg");
1099 assert_eq!(fields.mime_type, "image/jpeg");
1100 assert_eq!(fields.album_id, Some(42));
1101 }
1102
1103 #[test]
1104 fn build_finish_chunks_payload_preserves_file_metadata() {
1105 let payload = build_finish_chunks_payload(
1106 "upload-id".to_string(),
1107 "image.jpg".to_string(),
1108 "image/jpeg".to_string(),
1109 Some(42),
1110 );
1111
1112 assert_eq!(payload.files.len(), 1);
1113 let file = &payload.files[0];
1114 assert_eq!(file.uuid, "upload-id");
1115 assert_eq!(file.original, "image.jpg");
1116 assert_eq!(file.r#type, "image/jpeg");
1117 assert_eq!(file.albumid, Some(42));
1118 assert_eq!(file.filelength, None);
1119 assert_eq!(file.age, None);
1120 }
1121
1122 #[test]
1123 fn finish_chunks_url_replaces_upload_path() {
1124 let url = Url::parse("https://node.example/upload?token=abc").unwrap();
1125 let finish_url = finish_chunks_url(url);
1126
1127 assert_eq!(
1128 finish_url.as_str(),
1129 "https://node.example/api/upload/finishchunks?token=abc"
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn prepare_upload_file_rejects_missing_file_name_before_opening() {
1135 match prepare_upload_file(Path::new("")).await {
1136 Err(CyberdropError::InvalidFileName) => {}
1137 Err(err) => panic!("expected invalid file name, got {err}"),
1138 Ok(_) => panic!("expected invalid file name, got prepared upload"),
1139 }
1140 }
1141}