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#[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
29pub struct WSImageList {
32 pub images_list: Vec<CloudImage>,
33 pub website: Arc<WebSite>,
34}
35
36#[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
56async 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 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 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 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 if let Some((url, _date)) = url_with_latest_directory_name(name, list_of_dates, url) {
158 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 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 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 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 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 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 if one_file_count == 1 {
245 if let Some(checksum_entry) = one_file.first() {
247 if let Some(filename) = checksum_entry.name() {
250 let checksums = get_body_from_url(&format!("{}/{filename}", url.url), client).await;
252 trace!("checksums: {checksums:?}");
253 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 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 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 pub async fn get_images_list(website: Arc<WebSite>, concurrent_downloads: usize, db: Arc<DbImageHistory>) -> Self {
315 let client = reqwest::Client::new();
317
318 let url_list = website.generate_url_list().await;
322
323 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 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#[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
376fn url_with_latest_directory_name(name: &str, list_of_entries: HttpDirectory, url: &str) -> Option<(String, String)> {
379 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}