rustypaste/
paste.rs

1use crate::config::Config;
2use crate::file::Directory;
3use crate::header::ContentDisposition;
4use crate::util;
5use actix_web::{error, Error};
6use awc::Client;
7use std::fs::{self, File};
8use std::io::{Error as IoError, ErrorKind as IoErrorKind, Result as IoResult, Write};
9use std::path::{Path, PathBuf};
10use std::str;
11use std::sync::RwLock;
12use std::{
13    convert::{TryFrom, TryInto},
14    ops::Add,
15};
16use url::Url;
17
18/// Type of the data to store.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum PasteType {
21    /// Any type of file.
22    File,
23    /// A file that is on a remote URL.
24    RemoteFile,
25    /// A file that allowed to be accessed once.
26    Oneshot,
27    /// A file that only contains an URL.
28    Url,
29    /// A oneshot url.
30    OneshotUrl,
31}
32
33impl<'a> TryFrom<&'a ContentDisposition> for PasteType {
34    type Error = ();
35    fn try_from(content_disposition: &'a ContentDisposition) -> Result<Self, Self::Error> {
36        if content_disposition.has_form_field("file") {
37            Ok(Self::File)
38        } else if content_disposition.has_form_field("remote") {
39            Ok(Self::RemoteFile)
40        } else if content_disposition.has_form_field("oneshot") {
41            Ok(Self::Oneshot)
42        } else if content_disposition.has_form_field("oneshot_url") {
43            Ok(Self::OneshotUrl)
44        } else if content_disposition.has_form_field("url") {
45            Ok(Self::Url)
46        } else {
47            Err(())
48        }
49    }
50}
51
52impl PasteType {
53    /// Returns the corresponding directory of the paste type.
54    pub fn get_dir(&self) -> String {
55        match self {
56            Self::File | Self::RemoteFile => String::new(),
57            Self::Oneshot => String::from("oneshot"),
58            Self::Url => String::from("url"),
59            Self::OneshotUrl => String::from("oneshot_url"),
60        }
61    }
62
63    /// Returns the given path with [`directory`](Self::get_dir) adjoined.
64    pub fn get_path(&self, path: &Path) -> IoResult<PathBuf> {
65        let dir = self.get_dir();
66        if dir.is_empty() {
67            Ok(path.to_path_buf())
68        } else {
69            util::safe_path_join(path, Path::new(&dir))
70        }
71    }
72
73    /// Returns `true` if the variant is [`Oneshot`](Self::Oneshot).
74    pub fn is_oneshot(&self) -> bool {
75        self == &Self::Oneshot
76    }
77}
78
79/// Representation of a single paste.
80#[derive(Debug)]
81pub struct Paste {
82    /// Data to store.
83    pub data: Vec<u8>,
84    /// Type of the data.
85    pub type_: PasteType,
86}
87
88impl Paste {
89    /// Writes the bytes to a file in upload directory.
90    ///
91    /// - If `file_name` does not have an extension, it is replaced with [`default_extension`].
92    /// - If `file_name` is "-", it is replaced with "stdin".
93    /// - If [`random_url.enabled`] is `true`, `file_name` is replaced with a pet name or random string.
94    /// - If `header_filename` is set, it will override the filename.
95    ///
96    /// [`default_extension`]: crate::config::PasteConfig::default_extension
97    /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled
98    pub fn store_file(
99        &self,
100        file_name: &str,
101        expiry_date: Option<u128>,
102        header_filename: Option<String>,
103        config: &Config,
104    ) -> Result<String, Error> {
105        let file_type = infer::get(&self.data);
106        if let Some(file_type) = file_type {
107            for mime_type in &config.paste.mime_blacklist {
108                if mime_type == file_type.mime_type() {
109                    return Err(error::ErrorUnsupportedMediaType(
110                        "this file type is not permitted",
111                    ));
112                }
113            }
114        }
115
116        if let Some(max_dir_size) = config.server.max_upload_dir_size {
117            let file_size = u64::try_from(self.data.len()).unwrap_or_default();
118            let upload_dir = self.type_.get_path(&config.server.upload_path)?;
119            let current_size_of_upload_dir = util::get_dir_size(&upload_dir).map_err(|e| {
120                error::ErrorInternalServerError(format!("could not get directory size: {e}"))
121            })?;
122            let expected_size_of_upload_dir = current_size_of_upload_dir.add(file_size);
123            if expected_size_of_upload_dir > max_dir_size {
124                return Err(error::ErrorInsufficientStorage(
125                    "upload directory size limit exceeded",
126                ));
127            }
128        }
129
130        let mut file_name = match PathBuf::from(file_name)
131            .file_name()
132            .and_then(|v| v.to_str())
133        {
134            Some("-") => String::from("stdin"),
135            Some(".") => String::from("file"),
136            Some(v) => v.to_string(),
137            None => String::from("file"),
138        };
139        if let Some(handle_spaces_config) = config.server.handle_spaces {
140            file_name = handle_spaces_config.process_filename(&file_name);
141        }
142
143        let mut path =
144            util::safe_path_join(self.type_.get_path(&config.server.upload_path)?, &file_name)?;
145        let mut parts: Vec<&str> = file_name.split('.').collect();
146        let mut dotfile = false;
147        let mut lower_bound = 1;
148        let mut file_name = match parts[0] {
149            "" => {
150                // Index shifts one to the right in the array for the rest of the string (the extension)
151                dotfile = true;
152                lower_bound = 2;
153                // If the first array element is empty, it means the file started with a dot (e.g.: .foo)
154                format!(".{}", parts[1])
155            }
156            _ => parts[0].to_string(),
157        };
158        let mut extension = if parts.len() > lower_bound {
159            // To get the rest (the extension), we have to remove the first element of the array, which is the filename
160            parts.remove(0);
161            if dotfile {
162                // If the filename starts with a dot, we have to remove another element, because the first element was empty
163                parts.remove(0);
164            }
165            parts.join(".")
166        } else {
167            file_type
168                .map(|t| t.extension())
169                .unwrap_or(&config.paste.default_extension)
170                .to_string()
171        };
172        if let Some(random_url) = &config.paste.random_url {
173            if let Some(random_text) = random_url.generate() {
174                if let Some(suffix_mode) = random_url.suffix_mode {
175                    if suffix_mode {
176                        extension = format!("{}.{}", random_text, extension);
177                    } else {
178                        file_name = random_text;
179                    }
180                } else {
181                    file_name = random_text;
182                }
183            }
184        }
185        path.set_file_name(file_name);
186        path.set_extension(extension);
187        if let Some(header_filename) = header_filename {
188            file_name = header_filename;
189            path.set_file_name(file_name);
190        }
191        let file_name = path
192            .file_name()
193            .map(|v| v.to_string_lossy())
194            .unwrap_or_default()
195            .to_string();
196        let file_path = util::glob_match_file(path.clone())
197            .map_err(|_| IoError::new(IoErrorKind::Other, String::from("path is not valid")))?;
198        if file_path.is_file() && file_path.exists() {
199            return Err(error::ErrorConflict("file already exists\n"));
200        }
201        if let Some(timestamp) = expiry_date {
202            path.set_file_name(format!("{file_name}.{timestamp}"));
203        }
204        let mut buffer = File::create(&path)?;
205        buffer.write_all(&self.data)?;
206        Ok(file_name)
207    }
208
209    /// Downloads a file from URL and stores it with [`store_file`].
210    ///
211    /// - File name is inferred from URL if the last URL segment is a file.
212    /// - Same content length configuration is applied for download limit.
213    /// - Checks SHA256 digest of the downloaded file for preventing duplication.
214    /// - Assumes `self.data` contains a valid URL, otherwise returns an error.
215    ///
216    /// [`store_file`]: Self::store_file
217    pub async fn store_remote_file(
218        &mut self,
219        expiry_date: Option<u128>,
220        client: &Client,
221        config: &RwLock<Config>,
222    ) -> Result<String, Error> {
223        let data = str::from_utf8(&self.data).map_err(error::ErrorBadRequest)?;
224        let url = Url::parse(data).map_err(error::ErrorBadRequest)?;
225        let file_name = url
226            .path_segments()
227            .and_then(|segments| segments.last())
228            .and_then(|name| if name.is_empty() { None } else { Some(name) })
229            .unwrap_or("file");
230        let mut response = client
231            .get(url.as_str())
232            .send()
233            .await
234            .map_err(error::ErrorInternalServerError)?;
235        let payload_limit = config
236            .read()
237            .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?
238            .server
239            .max_content_length
240            .try_into()
241            .map_err(error::ErrorInternalServerError)?;
242        let bytes = response
243            .body()
244            .limit(payload_limit)
245            .await
246            .map_err(error::ErrorInternalServerError)?
247            .to_vec();
248        let config = config
249            .read()
250            .map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
251        let bytes_checksum = util::sha256_digest(&*bytes)?;
252        self.data = bytes;
253        if !config.paste.duplicate_files.unwrap_or(true) && expiry_date.is_none() {
254            if let Some(file) =
255                Directory::try_from(config.server.upload_path.as_path())?.get_file(bytes_checksum)
256            {
257                return Ok(file
258                    .path
259                    .file_name()
260                    .map(|v| v.to_string_lossy())
261                    .unwrap_or_default()
262                    .to_string());
263            }
264        }
265        self.store_file(file_name, expiry_date, None, &config)
266    }
267
268    /// Writes an URL to a file in upload directory.
269    ///
270    /// - Checks if the data is a valid URL.
271    /// - If [`random_url.enabled`] is `true`, file name is set to a pet name or random string.
272    ///
273    /// [`random_url.enabled`]: crate::random::RandomURLConfig::enabled
274    #[allow(deprecated)]
275    pub fn store_url(
276        &self,
277        expiry_date: Option<u128>,
278        header_filename: Option<String>,
279        config: &Config,
280    ) -> IoResult<String> {
281        let data = str::from_utf8(&self.data)
282            .map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?;
283        let url = Url::parse(data).map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?;
284        let mut file_name = self.type_.get_dir();
285        if let Some(random_url) = &config.paste.random_url {
286            if let Some(random_text) = random_url.generate() {
287                file_name = random_text;
288            }
289        }
290        if let Some(header_filename) = header_filename {
291            file_name = header_filename;
292        }
293        let mut path =
294            util::safe_path_join(self.type_.get_path(&config.server.upload_path)?, &file_name)?;
295        if let Some(timestamp) = expiry_date {
296            path.set_file_name(format!("{file_name}.{timestamp}"));
297        }
298        fs::write(&path, url.to_string())?;
299        Ok(file_name)
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::random::{RandomURLConfig, RandomURLType};
307    use crate::util;
308    use actix_web::web::Data;
309    use awc::ClientBuilder;
310    use byte_unit::Byte;
311    use std::env;
312    use std::str::FromStr;
313    use std::time::Duration;
314
315    #[actix_rt::test]
316    #[allow(deprecated)]
317    async fn test_paste_data() -> Result<(), Error> {
318        let mut config = Config::default();
319        config.server.upload_path = env::current_dir()?;
320        config.paste.random_url = Some(RandomURLConfig {
321            enabled: Some(true),
322            words: Some(3),
323            separator: Some(String::from("_")),
324            type_: RandomURLType::PetName,
325            ..RandomURLConfig::default()
326        });
327        let paste = Paste {
328            data: vec![65, 66, 67],
329            type_: PasteType::File,
330        };
331        let file_name = paste.store_file("test.txt", None, None, &config)?;
332        assert_eq!("ABC", fs::read_to_string(&file_name)?);
333        assert_eq!(
334            Some("txt"),
335            PathBuf::from(&file_name)
336                .extension()
337                .and_then(|v| v.to_str())
338        );
339        fs::remove_file(file_name)?;
340
341        config.paste.random_url = Some(RandomURLConfig {
342            length: Some(4),
343            type_: RandomURLType::Alphanumeric,
344            suffix_mode: Some(true),
345            ..RandomURLConfig::default()
346        });
347        let paste = Paste {
348            data: vec![116, 101, 115, 115, 117, 115],
349            type_: PasteType::File,
350        };
351        let file_name = paste.store_file("foo.tar.gz", None, None, &config)?;
352        assert_eq!("tessus", fs::read_to_string(&file_name)?);
353        assert!(file_name.ends_with(".tar.gz"));
354        assert!(file_name.starts_with("foo."));
355        fs::remove_file(file_name)?;
356
357        config.paste.random_url = Some(RandomURLConfig {
358            length: Some(4),
359            type_: RandomURLType::Alphanumeric,
360            suffix_mode: Some(true),
361            ..RandomURLConfig::default()
362        });
363        let paste = Paste {
364            data: vec![116, 101, 115, 115, 117, 115],
365            type_: PasteType::File,
366        };
367        let file_name = paste.store_file(".foo.tar.gz", None, None, &config)?;
368        assert_eq!("tessus", fs::read_to_string(&file_name)?);
369        assert!(file_name.ends_with(".tar.gz"));
370        assert!(file_name.starts_with(".foo."));
371        fs::remove_file(file_name)?;
372
373        config.paste.random_url = Some(RandomURLConfig {
374            length: Some(4),
375            type_: RandomURLType::Alphanumeric,
376            suffix_mode: Some(false),
377            ..RandomURLConfig::default()
378        });
379        let paste = Paste {
380            data: vec![116, 101, 115, 115, 117, 115],
381            type_: PasteType::File,
382        };
383        let file_name = paste.store_file("foo.tar.gz", None, None, &config)?;
384        assert_eq!("tessus", fs::read_to_string(&file_name)?);
385        assert!(file_name.ends_with(".tar.gz"));
386        fs::remove_file(file_name)?;
387
388        config.paste.default_extension = String::from("txt");
389        config.paste.random_url = None;
390        let paste = Paste {
391            data: vec![120, 121, 122],
392            type_: PasteType::File,
393        };
394        let file_name = paste.store_file(".foo", None, None, &config)?;
395        assert_eq!("xyz", fs::read_to_string(&file_name)?);
396        assert_eq!(".foo.txt", file_name);
397        fs::remove_file(file_name)?;
398
399        config.paste.default_extension = String::from("bin");
400        config.paste.random_url = Some(RandomURLConfig {
401            length: Some(10),
402            type_: RandomURLType::Alphanumeric,
403            ..RandomURLConfig::default()
404        });
405        let paste = Paste {
406            data: vec![120, 121, 122],
407            type_: PasteType::File,
408        };
409        let file_name = paste.store_file("random", None, None, &config)?;
410        assert_eq!("xyz", fs::read_to_string(&file_name)?);
411        assert_eq!(
412            Some("bin"),
413            PathBuf::from(&file_name)
414                .extension()
415                .and_then(|v| v.to_str())
416        );
417        fs::remove_file(file_name)?;
418
419        config.paste.random_url = Some(RandomURLConfig {
420            length: Some(4),
421            type_: RandomURLType::Alphanumeric,
422            suffix_mode: Some(true),
423            ..RandomURLConfig::default()
424        });
425        let paste = Paste {
426            data: vec![116, 101, 115, 115, 117, 115],
427            type_: PasteType::File,
428        };
429        let file_name = paste.store_file(
430            "filename.txt",
431            None,
432            Some("fn_from_header.txt".to_string()),
433            &config,
434        )?;
435        assert_eq!("tessus", fs::read_to_string(&file_name)?);
436        assert_eq!("fn_from_header.txt", file_name);
437        fs::remove_file(file_name)?;
438
439        config.paste.random_url = Some(RandomURLConfig {
440            length: Some(4),
441            type_: RandomURLType::Alphanumeric,
442            suffix_mode: Some(true),
443            ..RandomURLConfig::default()
444        });
445        let paste = Paste {
446            data: vec![116, 101, 115, 115, 117, 115],
447            type_: PasteType::File,
448        };
449        let file_name = paste.store_file(
450            "filename.txt",
451            None,
452            Some("fn_from_header".to_string()),
453            &config,
454        )?;
455        assert_eq!("tessus", fs::read_to_string(&file_name)?);
456        assert_eq!("fn_from_header", file_name);
457        fs::remove_file(file_name)?;
458
459        for paste_type in &[PasteType::Url, PasteType::Oneshot] {
460            fs::create_dir_all(
461                paste_type
462                    .get_path(&config.server.upload_path)
463                    .expect("Bad upload path"),
464            )?;
465        }
466
467        config.paste.random_url = None;
468        let paste = Paste {
469            data: vec![116, 101, 115, 116],
470            type_: PasteType::Oneshot,
471        };
472        let expiry_date = util::get_system_time()?.as_millis() + 100;
473        let file_name = paste.store_file("test.file", Some(expiry_date), None, &config)?;
474        let file_path = PasteType::Oneshot
475            .get_path(&config.server.upload_path)
476            .expect("Bad upload path")
477            .join(format!("{file_name}.{expiry_date}"));
478        assert_eq!("test", fs::read_to_string(&file_path)?);
479        fs::remove_file(file_path)?;
480
481        config.paste.random_url = Some(RandomURLConfig {
482            enabled: Some(true),
483            ..RandomURLConfig::default()
484        });
485        let url = String::from("https://orhun.dev/");
486        let paste = Paste {
487            data: url.as_bytes().to_vec(),
488            type_: PasteType::Url,
489        };
490        let file_name = paste.store_url(None, None, &config)?;
491        let file_path = PasteType::Url
492            .get_path(&config.server.upload_path)
493            .expect("Bad upload path")
494            .join(&file_name);
495        assert_eq!(url, fs::read_to_string(&file_path)?);
496        fs::remove_file(file_path)?;
497
498        let url = String::from("testurl.com");
499        let paste = Paste {
500            data: url.as_bytes().to_vec(),
501            type_: PasteType::Url,
502        };
503        assert!(paste.store_url(None, None, &config).is_err());
504
505        let url = String::from("https://orhun.dev/");
506        let paste = Paste {
507            data: url.as_bytes().to_vec(),
508            type_: PasteType::Url,
509        };
510        let prepared_result = paste.store_url(None, Some("prepared-name".to_string()), &config)?;
511        let file_path = PasteType::Url
512            .get_path(&config.server.upload_path)
513            .expect("Bad upload path")
514            .join(&prepared_result);
515        assert_eq!(prepared_result, "prepared-name");
516        assert_eq!(url, fs::read_to_string(&file_path)?);
517        fs::remove_file(file_path)?;
518
519        config.server.max_content_length = Byte::from_str("30k").expect("cannot parse byte");
520        let url = String::from("https://raw.githubusercontent.com/orhun/rustypaste/refs/heads/master/img/rp_test_3b5eeeee7a7326cd6141f54820e6356a0e9d1dd4021407cb1d5e9de9f034ed2f.png");
521        let mut paste = Paste {
522            data: url.as_bytes().to_vec(),
523            type_: PasteType::RemoteFile,
524        };
525        let client_data = Data::new(
526            ClientBuilder::new()
527                .timeout(Duration::from_secs(30))
528                .finish(),
529        );
530        let file_name = paste
531            .store_remote_file(None, &client_data, &RwLock::new(config.clone()))
532            .await?;
533        let file_path = PasteType::RemoteFile
534            .get_path(&config.server.upload_path)
535            .expect("Bad upload path")
536            .join(file_name);
537        assert_eq!(
538            "3b5eeeee7a7326cd6141f54820e6356a0e9d1dd4021407cb1d5e9de9f034ed2f",
539            util::sha256_digest(&*paste.data)?
540        );
541        fs::remove_file(file_path)?;
542
543        for paste_type in &[PasteType::Url, PasteType::Oneshot] {
544            fs::remove_dir(
545                paste_type
546                    .get_path(&config.server.upload_path)
547                    .expect("Bad upload path"),
548            )?;
549        }
550
551        Ok(())
552    }
553}