Skip to main content

btracker_fs/
public.rs

1//! Frontend features for the following βtracker project components:
2//!
3//! * https://github.com/YGGverse/btracker
4//! * https://github.com/YGGverse/btracker-gemini
5
6use chrono::{DateTime, Utc};
7use librqbit_core::Id20;
8use std::{
9    fs,
10    future::Future,
11    io::Error,
12    path::{Path, PathBuf},
13    str::FromStr,
14    time::SystemTime,
15};
16
17#[derive(Clone, Debug, Default)]
18pub enum Sort {
19    #[default]
20    Modified,
21}
22
23#[derive(Clone, Debug, Default)]
24pub enum Order {
25    #[default]
26    Asc,
27    Desc,
28}
29
30pub struct Torrent {
31    pub bytes: Vec<u8>,
32    pub time: DateTime<Utc>,
33}
34
35pub struct Storage {
36    default_capacity: usize,
37    pub default_limit: usize,
38    root: PathBuf,
39}
40
41impl Storage {
42    // Constructors
43
44    pub fn init(
45        root: &Path,
46        default_limit: usize,
47        default_capacity: usize,
48    ) -> Result<Self, String> {
49        if !root.is_dir() {
50            return Err("Public root is not directory".into());
51        }
52        Ok(Self {
53            default_capacity,
54            default_limit,
55            root: root.canonicalize().map_err(|e| e.to_string())?,
56        })
57    }
58
59    // Getters
60
61    pub fn torrent(&self, info_hash: Id20) -> Option<Torrent> {
62        let mut p = PathBuf::from(&self.root);
63        p.push(format!("{}.{E}", info_hash.as_string()));
64        Some(Torrent {
65            bytes: fs::read(&p).ok()?,
66            time: p.metadata().ok()?.modified().ok()?.into(),
67        })
68    }
69
70    pub async fn torrents<F, Fut>(
71        &self,
72        keyword: Option<&str>,
73        sort_order: Option<(Sort, Order)>,
74        start: Option<usize>,
75        limit: Option<usize>,
76        visibility_filter: F,
77    ) -> Result<Torrents, Error>
78    where
79        F: Fn(Id20) -> Fut,
80        Fut: Future<Output = bool>,
81    {
82        let f = self.files(keyword, sort_order)?;
83        let t = f.len(); // total
84        let l = limit.unwrap_or(t);
85        let s = start.unwrap_or_default();
86        let mut b = Vec::with_capacity(l);
87        let mut i = 0; // start offset
88        for file in f.iter() {
89            if let Some(n) = file.path.file_stem()
90                && let Ok(id20) = Id20::from_str(&n.to_string_lossy())
91                && visibility_filter(id20).await
92            {
93                if i >= s && b.len() < l {
94                    b.push(Torrent {
95                        bytes: fs::read(&file.path)?,
96                        time: file.modified.into(),
97                    });
98                }
99                i += 1;
100            }
101        }
102        Ok(Torrents {
103            total: t,
104            visible: i,
105            list: b,
106        })
107    }
108
109    /// Build URI for given `path`
110    ///
111    /// * result requires URL encode
112    pub fn href(&self, info_hash: &str, path: &str) -> Option<String> {
113        let mut relative = PathBuf::from(info_hash);
114        relative.push(path);
115
116        let mut absolute = PathBuf::from(&self.root);
117        absolute.push(&relative);
118
119        let c = absolute.canonicalize().ok()?;
120        if c.starts_with(&self.root) && c.exists() {
121            Some(relative.to_string_lossy().into())
122        } else {
123            None
124        }
125    }
126
127    /// Return canonical absolute path to file
128    ///
129    /// * `None` if the given URI does not exist or has denied location
130    pub fn filepath(&self, relative: &str) -> Option<PathBuf> {
131        let mut p = PathBuf::from(&self.root);
132        p.push(relative);
133
134        let c = p.canonicalize().ok()?;
135        if c.starts_with(&self.root) && c.is_file() {
136            Some(c)
137        } else {
138            None
139        }
140    }
141
142    // Helpers
143
144    fn files(
145        &self,
146        keyword: Option<&str>,
147        sort_order: Option<(Sort, Order)>,
148    ) -> Result<Vec<File>, Error> {
149        let mut files = Vec::with_capacity(self.default_capacity);
150        for dir_entry in fs::read_dir(&self.root)? {
151            let entry = dir_entry?;
152            let path = entry.path();
153            if !path.is_file() || path.extension().is_none_or(|e| e != E) {
154                continue;
155            }
156            if let Some(k) = keyword
157                && !k.trim_matches(S).is_empty()
158                && !librqbit_core::torrent_metainfo::torrent_from_bytes(&fs::read(&path)?)
159                    .is_ok_and(|m: librqbit_core::torrent_metainfo::TorrentMetaV1Owned| {
160                        k.split(S)
161                            .filter(|s| !s.is_empty())
162                            .map(|s| s.trim().to_lowercase())
163                            .all(|q| {
164                                m.info_hash.as_string().to_lowercase().contains(&q)
165                                    || m.info
166                                        .name
167                                        .as_ref()
168                                        .is_some_and(|n| n.to_string().to_lowercase().contains(&q))
169                                    || m.comment
170                                        .as_ref()
171                                        .is_some_and(|c| c.to_string().to_lowercase().contains(&q))
172                                    || m.created_by
173                                        .as_ref()
174                                        .is_some_and(|c| c.to_string().to_lowercase().contains(&q))
175                                    || m.publisher
176                                        .as_ref()
177                                        .is_some_and(|p| p.to_string().to_lowercase().contains(&q))
178                                    || m.publisher_url
179                                        .as_ref()
180                                        .is_some_and(|u| u.to_string().to_lowercase().contains(&q))
181                                    || m.announce
182                                        .as_ref()
183                                        .is_some_and(|a| a.to_string().to_lowercase().contains(&q))
184                                    || m.announce_list.iter().any(|l| {
185                                        l.iter().any(|a| a.to_string().to_lowercase().contains(&q))
186                                    })
187                                    || m.info.files.as_ref().is_some_and(|f| {
188                                        f.iter().any(|f| {
189                                            let mut p = PathBuf::new();
190                                            f.full_path(&mut p).is_ok_and(|_| {
191                                                p.to_string_lossy().to_lowercase().contains(&q)
192                                            })
193                                        })
194                                    })
195                            })
196                    })
197            {
198                continue;
199            }
200            files.push(File {
201                modified: entry.metadata()?.modified()?,
202                path,
203            })
204        }
205        if let Some((sort, order)) = sort_order {
206            match sort {
207                Sort::Modified => match order {
208                    Order::Asc => files.sort_by_key(|a| a.modified),
209                    Order::Desc => files.sort_by_key(|b| std::cmp::Reverse(b.modified)),
210                },
211            }
212        }
213        Ok(files)
214    }
215}
216
217// Local members
218
219/// Torrent file extension
220const E: &str = "torrent";
221
222/// Search keyword separators
223const S: &[char] = &[
224    '_', '-', ':', ';', ',', '(', ')', '[', ']', '/', '!', '?', ' ', // @TODO make optional
225];
226
227struct File {
228    modified: SystemTime,
229    path: PathBuf,
230}
231
232pub struct Torrents {
233    pub total: usize,
234    pub visible: usize,
235    pub list: Vec<Torrent>,
236}