Skip to main content

cloud_image_download/
website.rs

1use crate::CID_USER_AGENT;
2use crate::checksums::{CheckSums, are_all_checksums_in_one_file};
3use crate::cloud_image::CloudImage;
4use crate::image_history::DbImageHistory;
5use futures::{StreamExt, stream};
6use httpdirectory::error::HttpDirError;
7use httpdirectory::httpdirectory::HttpDirectory;
8use log::{debug, error, info, trace, warn};
9use regex::Regex;
10use reqwest::header::{ACCEPT, USER_AGENT};
11use serde::Deserialize;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15/// Website description structure
16#[derive(Debug, Deserialize)]
17pub struct WebSite {
18    pub name: String,
19    version_list: Vec<String>,
20    base_url: String,
21    after_version_url: Option<Vec<String>>,
22    image_name_filter: String,
23    image_name_cleanse: Option<Vec<String>>,
24    pub destination: PathBuf,
25    pub normalize: Option<String>,
26    timeout: Option<u64>,
27}
28
29/// Associates a list of images with the website
30/// they come from
31pub struct WSImageList {
32    pub images_list: Vec<CloudImage>,
33    pub website: Arc<WebSite>,
34}
35
36/// cloud-image-download Url type to get all the
37/// components of an Url from the configuration file
38#[derive(Default, PartialEq, Debug)]
39pub struct Url {
40    pub url: String,
41    pub version: Option<String>,
42    pub after_version: Option<String>,
43}
44
45impl Url {
46    #[must_use]
47    pub fn new(url: String, version: Option<String>, after_version: Option<String>) -> Self {
48        Url {
49            url,
50            version,
51            after_version,
52        }
53    }
54}
55
56/// Retrieves the body of a get request to the specified
57/// url and returns Some(body) if everything went fine
58/// and None in case of an Error
59async fn get_body_from_url(url: &str, client: &reqwest::Client) -> Option<String> {
60    match client.get(url).header(ACCEPT, "*/*").header(USER_AGENT, CID_USER_AGENT).send().await {
61        Ok(response) => match response.status() {
62            reqwest::StatusCode::OK => match response.text().await {
63                Ok(body) => Some(body),
64                Err(e) => {
65                    warn!("Error: no body in response: {e}");
66                    None
67                }
68            },
69            _ => {
70                warn!("Error while retrieving url '{url}' content ({})", response.status());
71                None
72            }
73        },
74        Err(e) => {
75            warn!("Error while fetching url '{url}': {e}");
76            None
77        }
78    }
79}
80
81fn sanitize_string(unsane: &str) -> String {
82    let mut sanitized = unsane.to_string();
83    if unsane.ends_with('/') {
84        sanitized = sanitized.replace('/', "");
85    }
86    sanitized.replace('/', "_")
87}
88
89impl WebSite {
90    /// Generates all url to be checked for images for this particular website
91    /// using versions from `version_list` and `after_version_url` that both
92    /// are vectors and may contain more than one element.
93    /// Checks whether the site has dates directories or numbered directorise
94    /// and in that case adds the latest one to the list instead of the url itself.
95    /// The returned list may be empty.
96    // @todo review this whole process
97    async fn generate_url_list(&self) -> Vec<Url> {
98        let mut url_list = vec![];
99        for version in &self.version_list {
100            let url = format!("{}/{}", self.base_url, version);
101
102            if let Some(url_checked) = self.check_for_directories_with_dates_or_version_numbers(&url, version).await {
103                if let Some(after) = &self.after_version_url {
104                    for after_version in after {
105                        // valid url_checked.url has a trailing /
106                        let new_url = Url::new(
107                            format!("{}{after_version}", url_checked.url),
108                            url_checked.version.clone(),
109                            Some(sanitize_string(after_version)),
110                        );
111                        info!("[{}] Adding url '{}' to the list of url", self.name, new_url.url);
112                        url_list.push(new_url);
113                    }
114                } else {
115                    info!("[{}] Adding url '{}' to the list of url", self.name, url_checked.url);
116                    url_list.push(url_checked);
117                }
118            } else {
119                info!("[{}] Adding url '{url}' to the list of url", self.name);
120                url_list.push(Url::new(url, Some(sanitize_string(version)), None));
121            }
122        }
123        url_list
124    }
125
126    /// Checks if an url has directories with dates and then returns the url
127    /// containing that directory instead of url itself. If the url has no
128    /// directories with dates then returns this url
129    /// Returns None when `HttpDirectory::new()` returns an Err.
130    async fn check_for_directories_with_dates_or_version_numbers(&self, url: &str, version: &str) -> Option<Url> {
131        let name = &self.name;
132        if let Ok(directory_listing) = HttpDirectory::new(url, self.timeout).await {
133            if let Ok(list_of_dates) = directory_listing.dirs().filter_by_name(r"\d{8}(?:-\d{4})?/$") {
134                if list_of_dates.is_empty() {
135                    debug!("[{name}] This url ({url}) has no dates in it");
136                    if let Ok(list_of_numbers) = directory_listing.dirs().filter_by_name(r"^\d\d+/$") {
137                        if list_of_numbers.is_empty() {
138                            debug!("[{name}] This url ({url}) has no numbers in it");
139                            return Some(Url {
140                                url: format!("{url}/"),
141                                version: None,
142                                after_version: None,
143                            });
144                        }
145                        debug!("[{name}] This url ({url}) has numbers in it:");
146                        if let Some((url, number)) = url_with_latest_directory_name(name, list_of_numbers, url) {
147                            return Some(Url {
148                                url,
149                                version: Some(number),
150                                after_version: None,
151                            });
152                        }
153                    }
154                } else {
155                    debug!("[{name}] This url ({url}) has dates in it:");
156                    // Keep only the latest entry !
157                    if let Some((url, _date)) = url_with_latest_directory_name(name, list_of_dates, url) {
158                        // When sanitizing we do not mind to get dates twice
159                        // so use the version name instead
160                        return Some(Url {
161                            url,
162                            version: Some(sanitize_string(version)),
163                            after_version: None,
164                        });
165                    }
166                }
167            } else {
168                return Some(Url {
169                    url: format!("{url}/"),
170                    version: Some(sanitize_string(version)),
171                    after_version: None,
172                });
173            }
174        }
175        None
176    }
177
178    /// Only retains entries from `HttpDirectory` listing that
179    /// does NOT match with any of the regular expressions found
180    /// in `image_name_cleanse` field
181    fn clean_httpdir_from_image_name_cleanse_regex(&self, image_list: HttpDirectory) -> HttpDirectory {
182        debug!("[{}] Cleaning (length: {}): {image_list}", self.name, image_list.len());
183        let mut filtered_image_list = image_list;
184        if let Some(regex_list_to_remove) = &self.image_name_cleanse {
185            for regex_to_remove in regex_list_to_remove {
186                if let Ok(re) = Regex::new(regex_to_remove) {
187                    debug!(" -> Using '{regex_to_remove}' as Regex");
188                    filtered_image_list = filtered_image_list.filtering(|e| !e.is_match_by_name(&re));
189                }
190            }
191        }
192        debug!("[{}] Cleaned (length: {}): {filtered_image_list}", self.name, filtered_image_list.len());
193        filtered_image_list
194    }
195
196    /// Adds the latest image that can be gathered from this `url`.
197    /// Downloads through `client` connection if possible a checksum
198    /// file and extracts the checksum. Returns a `Option<CloudImage>`
199    /// that represents the latest downloadable image if any)
200    ///
201    /// # Errors
202    /// May return an `HttpDirError` if getting the `HttpDirectory` for
203    /// this url fails
204    ///
205    /// @todo: simplify
206    async fn get_latest_image_to_download_from_url(
207        &self,
208        url: &Url,
209        client: &reqwest::Client,
210        db: &DbImageHistory,
211    ) -> Result<Option<CloudImage>, HttpDirError> {
212        let mut option_cloud_image: Option<CloudImage> = None;
213
214        // Getting all files whose name matches the regex self.image_name_filter and
215        // that does not matches *any* of the cimage_name_cleanse regex vector entry
216        let url_httpdir = HttpDirectory::new(&url.url, self.timeout).await?;
217        let http_image_list = url_httpdir.files().filter_by_name(&self.image_name_filter)?;
218        debug!(
219            "[{}] Retrieved {} files filtered with '{}' filter from {}",
220            self.name,
221            http_image_list.len(),
222            self.image_name_filter,
223            url.url
224        );
225        let http_image_list = self.clean_httpdir_from_image_name_cleanse_regex(http_image_list);
226
227        // Keeping only the newest entry from that list (sorted in descending order)
228        if let Some(image) = http_image_list.sort_by_date(false).first()
229            && let Some(image_name) = image.name()
230            && let Some(date) = image.date()
231        {
232            // Trying to find if we have a file that contains all checksums for
233            // the files to be downloaded
234            let one_file = url_httpdir.files().filtering(|e| {
235                are_all_checksums_in_one_file(e.name().expect(
236                    ".files() filter should return only files with names and thus .name() should never be None",
237                ))
238            });
239            let one_file_count = one_file.len();
240            debug!("Checksum guess: all in one file: {one_file_count}");
241            // We choose to download only one file if possible: we test onefile
242            // at first for this
243
244            if one_file_count == 1 {
245                // We only have one file with all checksums so get it:
246                if let Some(checksum_entry) = one_file.first() {
247                    // Download the checksum file with filename (url/filename)
248                    // retrieving the image name's checksum from that file.
249                    if let Some(filename) = checksum_entry.name() {
250                        // downloading the checksum file
251                        let checksums = get_body_from_url(&format!("{}/{filename}", url.url), client).await;
252                        trace!("checksums: {checksums:?}");
253                        // Finds the image_name in the checksum list and get it's checksum if any
254                        let checksum =
255                            CheckSums::get_image_checksum_from_checksums_buffer(image_name, &checksums, filename);
256                        let new_url = Url {
257                            url: format!("{}/{image_name}", url.url),
258                            version: url.version.clone(),
259                            after_version: url.after_version.clone(),
260                        };
261                        option_cloud_image = Some(CloudImage::new(new_url, checksum, image_name.to_string(), date));
262                    }
263                }
264            } else {
265                // We know that ".SHA256SUM" is a correct Regex so filter_by_name should never
266                // return an Error here
267                let everyfile = url_httpdir
268                    .files()
269                    .filter_by_name(".SHA256SUM")
270                    .expect(".files() filter should return only files with names and thus .name() should never be None")
271                    .len();
272                if everyfile >= 1 {
273                    // Downloading a checksum file that contains only the checksums of the image file
274                    let checksum_filename = format!("{}.SHA256SUM", url.url);
275                    let checksum_body = get_body_from_url(&checksum_filename, client).await;
276                    let checksum =
277                        CheckSums::get_image_checksum_from_checksums_buffer(image_name, &checksum_body, &url.url);
278                    let new_url = Url {
279                        url: url.url.clone(),
280                        version: url.version.clone(),
281                        after_version: url.after_version.clone(),
282                    };
283                    option_cloud_image = Some(CloudImage::new(new_url, checksum, image_name.to_string(), date));
284                } else {
285                    let new_url = Url {
286                        url: format!("{}/{image_name}", url.url),
287                        version: url.version.clone(),
288                        after_version: url.after_version.clone(),
289                    };
290                    option_cloud_image = Some(CloudImage::new(new_url, CheckSums::None, image_name.to_string(), date));
291                }
292            }
293        }
294
295        if let Some(cloud_image) = option_cloud_image {
296            if cloud_image.is_in_db(db) {
297                warn!("Image {} is already in database", cloud_image.url.url);
298                Ok(None)
299            } else {
300                info!("Image {} is not already in database", cloud_image.url.url);
301                Ok(Some(cloud_image))
302            }
303        } else {
304            Ok(None)
305        }
306    }
307}
308
309impl WSImageList {
310    /// Retrieves for this website all downloadable images and makes an
311    /// `ImageList` (ie an image url and an associated checksum).
312    /// Returns a `WSImageList` formed with the website itself and a vector of
313    /// `CloudImage`
314    pub async fn get_images_list(website: Arc<WebSite>, concurrent_downloads: usize, db: Arc<DbImageHistory>) -> Self {
315        // Creates a reqwest client to fetch url with.
316        let client = reqwest::Client::new();
317
318        // Generate a list of all url to be checked upon the
319        // configuration and how is organized the website
320        // itself (ie with or without dates directories)
321        let url_list = website.generate_url_list().await;
322
323        // Doing I/O get reqwest has much as possible in
324        // a parallel way
325        let lists = stream::iter(url_list)
326            .map(|url| {
327                let client = &client;
328                let website = website.clone();
329                let db = db.clone();
330                async move {
331                    match website.get_latest_image_to_download_from_url(&url, client, &db).await {
332                        Ok(cloud_image) => cloud_image,
333                        Err(error) => {
334                            error!("[{}] Error with url ({}) retrieving image list: {error}", website.name, url.url);
335                            None
336                        }
337                    }
338                }
339            })
340            .buffered(concurrent_downloads);
341
342        let all_cloud_images: Vec<Option<CloudImage>> = lists.collect().await;
343        debug!("[{}] Number of images: {}", website.name, all_cloud_images.len());
344        let images_list: Vec<CloudImage> = all_cloud_images.into_iter().flatten().collect();
345
346        /*
347                for option_cloud_image in all_cloud_images {
348                    if let Some(cloud_image) = option_cloud_image {
349                        images_list.push(cloud_image);
350                    }
351                }
352        */
353        WSImageList {
354            images_list,
355            website,
356        }
357    }
358
359    #[must_use]
360    pub fn is_empty(&self) -> bool {
361        self.images_list.is_empty()
362    }
363}
364
365/// Returns true only if all `WSImageList` contained in the vector
366/// `all_ws_image_list` are empty. Returns false otherwise
367#[must_use]
368pub fn vec_ws_image_lists_is_empty(all_ws_image_lists: &Vec<WSImageList>) -> bool {
369    let mut is_empty = true;
370    for ws_image in all_ws_image_lists {
371        is_empty = is_empty && ws_image.is_empty();
372    }
373    is_empty
374}
375
376/// Returns an url formed with the last directory name found
377/// in the `list_of_entries` if any.
378fn url_with_latest_directory_name(name: &str, list_of_entries: HttpDirectory, url: &str) -> Option<(String, String)> {
379    // Sorting in descending order
380    if let Some(entry) = list_of_entries.sort_by_name(false).first() {
381        if let Some(dirname) = entry.dirname() {
382            debug!("[{name}] Adding {dirname}");
383            Some((format!("{url}/{dirname}"), sanitize_string(dirname)))
384        } else {
385            debug!("[{name}] Error getting directory name");
386            None
387        }
388    } else {
389        debug!("[{name}] Error while trying to get the latest directory entry");
390        None
391    }
392}