modde_sources/gdrive/
mod.rs1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use reqwest::Client;
6use tracing::debug;
7
8use modde_core::manifest::wabbajack::DownloadDirective;
9
10use crate::common::{ensure_parent, stream_to_file, verify_and_wrap};
11use crate::traits::{DownloadHandle, DownloadSource, ProgressCallback, VerifiedFile};
12
13pub struct GoogleDriveSource {
17 client: Client,
18}
19
20impl GoogleDriveSource {
21 pub fn new(client: Client) -> Self {
22 Self { client }
23 }
24}
25
26impl DownloadSource for GoogleDriveSource {
27 fn can_handle(&self, directive: &DownloadDirective) -> bool {
28 matches!(directive, DownloadDirective::GoogleDrive { .. })
29 }
30
31 async fn resolve(&self, directive: &DownloadDirective) -> Result<DownloadHandle> {
32 let DownloadDirective::GoogleDrive { id, hash } = directive else {
33 anyhow::bail!("not a Google Drive directive");
34 };
35
36 let url = format!("https://drive.google.com/uc?id={id}&export=download");
37
38 Ok(DownloadHandle {
39 url,
40 headers: HashMap::new(),
41 expected_hash: *hash,
42 size_hint: None,
43 })
44 }
45
46 async fn download_with_progress(
47 &self,
48 handle: DownloadHandle,
49 dest: &Path,
50 progress: ProgressCallback,
51 ) -> Result<VerifiedFile> {
52 ensure_parent(dest).await?;
53
54 do_download(&self.client, &handle, dest, &progress)
55 .await
56 .context("Google Drive download failed")?;
57
58 verify_and_wrap(dest, handle.expected_hash).await
59 }
60}
61
62async fn do_download(
63 client: &Client,
64 handle: &DownloadHandle,
65 dest: &Path,
66 progress: &ProgressCallback,
67) -> Result<()> {
68 let resp = client.get(&handle.url).send().await?.error_for_status()?;
69 let content_type = resp
70 .headers()
71 .get("content-type")
72 .and_then(|v| v.to_str().ok())
73 .unwrap_or("")
74 .to_string();
75
76 if content_type.contains("text/html") {
78 debug!("got virus scan warning page, extracting confirm token");
79 let body = resp.text().await?;
80 let confirm_token = extract_confirm_token(&body)
81 .ok_or_else(|| anyhow::anyhow!("failed to extract confirm token from virus scan page"))?;
82
83 let confirmed_url = format!("{}&confirm={confirm_token}", handle.url);
84 let resp = client.get(&confirmed_url).send().await?.error_for_status()?;
85 stream_to_file(resp, dest, handle.size_hint.unwrap_or(0), progress).await?;
86 } else {
87 stream_to_file(resp, dest, handle.size_hint.unwrap_or(0), progress).await?;
88 }
89 Ok(())
90}
91
92fn extract_confirm_token(html: &str) -> Option<String> {
94 if let Some(pos) = html.find("confirm=") {
97 let rest = &html[pos + 8..];
98 let end = rest.find(|c: char| c == '&' || c == '"' || c == '\'' || c.is_whitespace())?;
99 let token = &rest[..end];
100 if !token.is_empty() {
101 return Some(token.to_string());
102 }
103 }
104 if let Some(pos) = html.find("name=\"confirm\"") {
106 let rest = &html[pos..];
107 if let Some(val_pos) = rest.find("value=\"") {
108 let val_rest = &rest[val_pos + 7..];
109 let end = val_rest.find('"')?;
110 let token = &val_rest[..end];
111 if !token.is_empty() {
112 return Some(token.to_string());
113 }
114 }
115 }
116 if let Some(pos) = html.find("id=\"uc-download-link\"") {
118 let rest = &html[pos..];
119 if let Some(href_pos) = rest.find("confirm=") {
120 let val_rest = &rest[href_pos + 8..];
121 let end = val_rest.find(|c: char| c == '&' || c == '"' || c == '\'')?;
122 let token = &val_rest[..end];
123 if !token.is_empty() {
124 return Some(token.to_string());
125 }
126 }
127 }
128 None
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use modde_core::GameId;
135
136 #[test]
139 fn confirm_token_pattern1_ampersand_delimited() {
140 let html = r#"<a href="https://drive.google.com/uc?id=ID&confirm=t&export=download">Download</a>"#;
141 assert_eq!(extract_confirm_token(html), Some("t".to_string()));
142 }
143
144 #[test]
145 fn confirm_token_pattern1_long_token() {
146 let html = r#"something confirm=AbCdEfGh1234&rest"#;
147 assert_eq!(
148 extract_confirm_token(html),
149 Some("AbCdEfGh1234".to_string())
150 );
151 }
152
153 #[test]
154 fn confirm_token_pattern1_quote_delimited() {
155 let html = r#"href="https://example.com?confirm=mytoken""#;
156 assert_eq!(extract_confirm_token(html), Some("mytoken".to_string()));
157 }
158
159 #[test]
160 fn confirm_token_pattern1_single_quote_delimited() {
161 let html = r#"href='https://example.com?confirm=tok123'"#;
162 assert_eq!(extract_confirm_token(html), Some("tok123".to_string()));
163 }
164
165 #[test]
166 fn confirm_token_pattern1_whitespace_delimited() {
167 let html = "url?confirm=TOKEN rest of text";
168 assert_eq!(extract_confirm_token(html), Some("TOKEN".to_string()));
169 }
170
171 #[test]
174 fn confirm_token_pattern2_input_field() {
175 let html =
176 r#"<input type="hidden" name="confirm" value="SecretVal"><input type="submit">"#;
177 assert_eq!(
178 extract_confirm_token(html),
179 Some("SecretVal".to_string())
180 );
181 }
182
183 #[test]
184 fn confirm_token_pattern2_with_extra_attrs() {
185 let html =
186 r#"<input class="foo" name="confirm" id="bar" value="TOKEN42">"#;
187 assert_eq!(extract_confirm_token(html), Some("TOKEN42".to_string()));
188 }
189
190 #[test]
193 fn confirm_token_pattern3_uc_download_link() {
194 let html = r#"<a id="uc-download-link" href="/uc?export=download&confirm=XyZ123&id=abc">Download anyway</a>"#;
195 assert_eq!(extract_confirm_token(html), Some("XyZ123".to_string()));
196 }
197
198 #[test]
199 fn confirm_token_pattern3_uc_download_link_quote_end() {
200 let html =
201 r#"<a id="uc-download-link" href="/uc?export=download&confirm=TOK">"#;
202 assert_eq!(extract_confirm_token(html), Some("TOK".to_string()));
203 }
204
205 #[test]
208 fn confirm_token_no_match_random_html() {
209 let html = "<html><body><p>Hello world</p></body></html>";
210 assert_eq!(extract_confirm_token(html), None);
211 }
212
213 #[test]
214 fn confirm_token_no_match_empty_string() {
215 assert_eq!(extract_confirm_token(""), None);
216 }
217
218 #[test]
219 fn confirm_token_no_match_similar_but_not_confirm() {
220 let html = r#"<input name="confirmed" value="nope">"#;
221 assert_eq!(extract_confirm_token(html), None);
227 }
228
229 #[test]
230 fn confirm_token_empty_token_returns_none() {
231 let html = "confirm=&rest";
233 assert_eq!(extract_confirm_token(html), None);
235 }
236
237 #[test]
240 fn can_handle_google_drive_directive() {
241 let source = GoogleDriveSource::new(Client::new());
242 let directive = DownloadDirective::GoogleDrive {
243 id: "1AbCdEfGh".to_string(),
244 hash: 42,
245 };
246 assert!(source.can_handle(&directive));
247 }
248
249 #[test]
250 fn can_handle_rejects_mega() {
251 let source = GoogleDriveSource::new(Client::new());
252 let directive = DownloadDirective::Mega {
253 url: "https://mega.nz/file/X#Y".to_string(),
254 hash: 0,
255 };
256 assert!(!source.can_handle(&directive));
257 }
258
259 #[test]
260 fn can_handle_rejects_nexus() {
261 let source = GoogleDriveSource::new(Client::new());
262 let directive = DownloadDirective::Nexus {
263 game_id: GameId::from("skyrim"),
264 mod_id: 1,
265 file_id: 1,
266 hash: 0,
267 };
268 assert!(!source.can_handle(&directive));
269 }
270
271 #[test]
272 fn can_handle_rejects_github() {
273 let source = GoogleDriveSource::new(Client::new());
274 let directive = DownloadDirective::GitHub {
275 user: "u".to_string(),
276 repo: "r".to_string(),
277 tag: "t".to_string(),
278 asset: "a".to_string(),
279 hash: 0,
280 };
281 assert!(!source.can_handle(&directive));
282 }
283
284 #[test]
285 fn can_handle_rejects_direct_url() {
286 let source = GoogleDriveSource::new(Client::new());
287 let directive = DownloadDirective::DirectURL {
288 url: "https://example.com/file".to_string(),
289 headers: std::collections::HashMap::new(),
290 hash: 0,
291 };
292 assert!(!source.can_handle(&directive));
293 }
294}