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