rustypaste_cli/
upload.rs

1use crate::config::Config;
2use crate::error::{Error, Result};
3use indicatif::{ProgressBar, ProgressStyle};
4use multipart::client::lazy::Multipart;
5use serde::Deserialize;
6use std::io::{Read, Result as IoResult, Write};
7use std::time::Duration;
8use ureq::Error as UreqError;
9use ureq::{Agent, AgentBuilder};
10use url::Url;
11
12/// Default file name to use for multipart stream.
13const DEFAULT_FILE_NAME: Option<&str> = Some("file");
14
15/// HTTP header to use for specifying expiration times.
16const EXPIRATION_HEADER: &str = "expire";
17
18/// HTTP header for specifying the filename.
19const FILENAME_HEADER: &str = "filename";
20
21/// File entry item for list endpoint.
22#[derive(Deserialize, Debug)]
23pub struct ListItem {
24    /// Uploaded file name.
25    pub file_name: String,
26    /// Size of the file in bytes.
27    pub file_size: u64,
28    /// ISO8601 formatted date-time string of the creation timestamp.
29    #[serde(default = "creation_date_utc_default")]
30    pub creation_date_utc: String,
31    /// ISO8601 formatted date-time string of the expiration timestamp if one exists for this file.
32    pub expires_at_utc: Option<String>,
33}
34
35fn creation_date_utc_default() -> String {
36    "info not available".to_string()
37}
38
39/// Wrapper around raw data and result.
40#[derive(Debug)]
41pub struct UploadResult<'a, T>(pub &'a str, pub Result<T>);
42
43/// Upload progress tracker.
44#[derive(Debug)]
45pub struct UploadTracker<'a, R: Read> {
46    /// Inner type for the upload stream.
47    inner: R,
48    /// Progress bar.
49    progress_bar: &'a ProgressBar,
50    /// Uploaded size.
51    uploaded: usize,
52}
53
54impl<'a, R: Read> UploadTracker<'a, R> {
55    /// Constructs a new instance.
56    pub fn new(progress_bar: &'a ProgressBar, total: u64, reader: R) -> Result<Self> {
57        progress_bar.set_style(
58            ProgressStyle::default_bar()
59                .template("{msg:.green.bold} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")?
60                .progress_chars("#>-"),
61        );
62        progress_bar.set_length(total);
63        progress_bar.reset_elapsed();
64        Ok(Self {
65            inner: reader,
66            progress_bar,
67            uploaded: 0,
68        })
69    }
70}
71
72impl<R: Read> Read for UploadTracker<'_, R> {
73    fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
74        let bytes_read = self.inner.read(buf)?;
75        self.uploaded += bytes_read;
76        self.progress_bar.set_position(self.uploaded as u64);
77        Ok(bytes_read)
78    }
79}
80
81/// Upload handler.
82#[derive(Debug)]
83pub struct Uploader<'a> {
84    /// HTTP client.
85    client: Agent,
86    /// Server configuration.
87    config: &'a Config,
88}
89
90impl<'a> Uploader<'a> {
91    /// Constructs a new instance.
92    pub fn new(config: &'a Config) -> Self {
93        Self {
94            client: AgentBuilder::new()
95                .user_agent(&format!(
96                    "{}/{}",
97                    env!("CARGO_PKG_NAME"),
98                    env!("CARGO_PKG_VERSION")
99                ))
100                .build(),
101            config,
102        }
103    }
104
105    /// Uploads the given file to the server.
106    pub fn upload_file(&self, file: &'a str) -> UploadResult<'a, String> {
107        let field = if self.config.paste.oneshot == Some(true) {
108            "oneshot"
109        } else {
110            "file"
111        };
112        let mut multipart = Multipart::new();
113        multipart.add_file(field, file);
114
115        UploadResult(file, self.upload(multipart))
116    }
117
118    /// Uploads the given URL (stream) to the server.
119    pub fn upload_url(&self, url: &'a str) -> UploadResult<'a, String> {
120        let field = if self.config.paste.oneshot == Some(true) {
121            "oneshot_url"
122        } else {
123            "url"
124        };
125
126        if let Err(e) = Url::parse(url) {
127            UploadResult(url, Err(e.into()))
128        } else {
129            let mut multipart = Multipart::new();
130            multipart.add_stream::<_, &[u8], &str>(field, url.as_bytes(), None, None);
131            UploadResult(url, self.upload(multipart))
132        }
133    }
134
135    /// Uploads the given remote URL (stream) to the server.
136    pub fn upload_remote_url(&self, url: &'a str) -> UploadResult<'a, String> {
137        if let Err(e) = Url::parse(url) {
138            UploadResult(url, Err(e.into()))
139        } else {
140            let mut multipart = Multipart::new();
141            multipart.add_stream::<_, &[u8], &str>("remote", url.as_bytes(), None, None);
142            UploadResult(url, self.upload(multipart))
143        }
144    }
145
146    /// Uploads a stream to the server.
147    pub fn upload_stream<S: Read>(&self, stream: S) -> UploadResult<'a, String> {
148        let field = if self.config.paste.oneshot == Some(true) {
149            "oneshot"
150        } else {
151            "file"
152        };
153        let mut multipart = Multipart::new();
154        multipart.add_stream(field, stream, DEFAULT_FILE_NAME, None);
155
156        UploadResult("stream", self.upload(multipart))
157    }
158
159    /// Uploads the given multipart data.
160    fn upload(&self, mut multipart: Multipart<'static, '_>) -> Result<String> {
161        let multipart_data = multipart.prepare()?;
162        let mut request = self.client.post(&self.config.server.address).set(
163            "Content-Type",
164            &format!(
165                "multipart/form-data; boundary={}",
166                multipart_data.boundary()
167            ),
168        );
169        if let Some(content_len) = multipart_data.content_len() {
170            request = request.set("Content-Length", &content_len.to_string());
171        }
172        if let Some(auth_token) = &self.config.server.auth_token {
173            request = request.set("Authorization", auth_token);
174        }
175        if let Some(expiration_time) = &self.config.paste.expire {
176            request = request.set(EXPIRATION_HEADER, expiration_time);
177        }
178        if let Some(filename) = &self.config.paste.filename {
179            request = request.set(FILENAME_HEADER, filename);
180        }
181        let progress_bar = ProgressBar::new_spinner();
182        progress_bar.enable_steady_tick(Duration::from_millis(80));
183        progress_bar.set_message("Uploading");
184        let upload_tracker = UploadTracker::new(
185            &progress_bar,
186            multipart_data.content_len().unwrap_or_default(),
187            multipart_data,
188        )?;
189        let result = match request.send(upload_tracker) {
190            Ok(response) => {
191                let status = response.status();
192                let response_text = response.into_string()?;
193                if response_text.lines().count() != 1 {
194                    Err(Error::UploadError(format!(
195                        "server returned invalid body (status code: {status})"
196                    )))
197                } else if status == 200 {
198                    Ok(response_text)
199                } else {
200                    Err(Error::UploadError(format!(
201                        "unknown error (status code: {status})"
202                    )))
203                }
204            }
205            Err(UreqError::Status(code, response)) => Err(Error::UploadError(format!(
206                "{} (status code: {})",
207                response.into_string()?.trim(),
208                code
209            ))),
210            Err(e) => Err(Error::RequestError(Box::new(e))),
211        };
212        progress_bar.finish_and_clear();
213        result
214    }
215
216    /// Wrapper: Delete the given file from the server.
217    pub fn delete_file(&self, file: &'a str) -> UploadResult<'a, String> {
218        UploadResult(file, self.delete(file))
219    }
220
221    /// Delete the given file from the server.
222    fn delete(&self, file: &'a str) -> Result<String> {
223        let url = self.retrieve_url(file)?;
224        let mut request = self.client.delete(url.as_str());
225        if let Some(delete_token) = &self.config.server.delete_token {
226            request = request.set("Authorization", delete_token);
227        }
228        let result = match request.call() {
229            Ok(response) => {
230                let status = response.status();
231                let response_text = response.into_string()?;
232                if status == 200 {
233                    Ok(response_text)
234                } else {
235                    Err(Error::DeleteError(format!(
236                        "unknown error (status code: {status})"
237                    )))
238                }
239            }
240            Err(UreqError::Status(code, response)) => {
241                if code == 404 {
242                    Err(Error::DeleteError(
243                        response.into_string()?.trim().to_string(),
244                    ))
245                } else {
246                    Err(Error::DeleteError(format!(
247                        "{} (status code: {})",
248                        response.into_string()?.trim(),
249                        code
250                    )))
251                }
252            }
253            Err(e) => Err(Error::RequestError(Box::new(e))),
254        };
255        result
256    }
257
258    /// Returns a valid request URL for an endpoint.
259    pub fn retrieve_url(&self, endpoint: &str) -> Result<Url> {
260        let mut url = Url::parse(&self.config.server.address)?;
261        if !url.path().to_string().ends_with('/') {
262            url = url.join(&format!("{}/", url.path()))?;
263        }
264        url = url.join(endpoint)?;
265        Ok(url)
266    }
267
268    /// Returns the server version.
269    pub fn retrieve_version(&self) -> Result<String> {
270        let url = self.retrieve_url("version")?;
271        let mut request = self.client.get(url.as_str());
272        if let Some(auth_token) = &self.config.server.auth_token {
273            request = request.set("Authorization", auth_token);
274        }
275        Ok(request
276            .call()
277            .map_err(|e| Error::RequestError(Box::new(e)))?
278            .into_string()?)
279    }
280
281    /// Retrieves and prints the files on server.
282    pub fn retrieve_list<Output: Write>(&self, output: &mut Output, prettify: bool) -> Result<()> {
283        let url = self.retrieve_url("list")?;
284        let mut request = self.client.get(url.as_str());
285        if let Some(auth_token) = &self.config.server.auth_token {
286            request = request.set("Authorization", auth_token);
287        }
288        let response = request
289            .call()
290            .map_err(|e| Error::RequestError(Box::new(e)))?;
291        if !prettify {
292            writeln!(output, "{}", response.into_string()?)?;
293            return Ok(());
294        }
295        let items: Vec<ListItem> = response.into_json()?;
296        if items.is_empty() {
297            writeln!(output, "No files on server :(")?;
298            return Ok(());
299        }
300        let filename_width = items
301            .iter()
302            .map(|v| v.file_name.len())
303            .max()
304            .unwrap_or_default();
305        let mut filesize_width = items
306            .iter()
307            .map(|v| v.file_size)
308            .max()
309            .unwrap_or_default()
310            .to_string()
311            .len();
312        if filesize_width < 4 {
313            filesize_width = 4;
314        }
315        writeln!(
316            output,
317            "{:^filename_width$} | {:^filesize_width$} | {:^19} | {:^19}",
318            "Name", "Size", "Creation (UTC)", "Expiry (UTC)"
319        )?;
320        writeln!(
321            output,
322            "{:-<filename_width$}-|-{:->filesize_width$}-|-{:-<19}-|-{:-<19}",
323            "", "", "", ""
324        )?;
325        items.iter().try_for_each(|file_info| {
326            writeln!(
327                output,
328                "{:<filename_width$} | {:>filesize_width$} | {:<19} | {}",
329                file_info.file_name,
330                file_info.file_size,
331                file_info.creation_date_utc,
332                file_info
333                    .expires_at_utc
334                    .as_ref()
335                    .cloned()
336                    .unwrap_or_default()
337            )
338        })?;
339        Ok(())
340    }
341}