lastfm_edit/
login.rs

1use crate::types::{LastFmEditSession, LastFmError};
2use crate::Result;
3use http_client::{HttpClient, Request};
4use http_types::{Method, Url};
5use scraper::{Html, Selector};
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// Login functionality separated from the main client
10pub struct LoginManager {
11    client: Arc<dyn HttpClient + Send + Sync>,
12    base_url: String,
13}
14
15impl LoginManager {
16    pub fn new(client: Arc<dyn HttpClient + Send + Sync>, base_url: String) -> Self {
17        Self { client, base_url }
18    }
19
20    /// Authenticate with Last.fm using username and password.
21    ///
22    /// This method:
23    /// 1. Fetches the login page to extract CSRF tokens
24    /// 2. Submits the login form with credentials
25    /// 3. Validates the authentication by checking for session cookies
26    /// 4. Returns a valid session for use with the client
27    ///
28    /// # Arguments
29    ///
30    /// * `username` - Last.fm username or email
31    /// * `password` - Last.fm password
32    ///
33    /// # Returns
34    ///
35    /// Returns a [`LastFmEditSession`] on successful authentication, or [`LastFmError::Auth`] on failure.
36    pub async fn login(&self, username: &str, password: &str) -> Result<LastFmEditSession> {
37        log::info!("🔐 Starting Last.fm login for username: {username}");
38
39        // Step 1: Fetch login page and extract CSRF token and cookies
40        let login_url = format!("{}/login", self.base_url);
41        let (csrf_token, next_field, mut cookies) = self.fetch_login_page(&login_url).await?;
42
43        // Step 2: Submit login form
44        let response = self
45            .submit_login_form(
46                &login_url,
47                username,
48                password,
49                &csrf_token,
50                &next_field,
51                &cookies,
52            )
53            .await?;
54
55        // Step 3: Extract cookies from login response
56        extract_cookies_from_response(&response, &mut cookies);
57        log::debug!("🍪 Cookies after login response: {cookies:?}");
58
59        // Step 4: Validate login response
60        self.validate_login_response(response, username, cookies, csrf_token)
61            .await
62    }
63
64    /// Fetch the login page and extract CSRF token, next field, and cookies
65    async fn fetch_login_page(
66        &self,
67        login_url: &str,
68    ) -> Result<(String, Option<String>, Vec<String>)> {
69        log::debug!("📡 Fetching login page: {login_url}");
70        let mut response = self.get(login_url).await?;
71
72        log::debug!("📋 Login page response status: {}", response.status());
73        log::debug!(
74            "📋 Login page response headers: {:?}",
75            response.iter().collect::<Vec<_>>()
76        );
77
78        // Extract cookies from the login page response
79        let mut cookies = Vec::new();
80        extract_cookies_from_response(&response, &mut cookies);
81        log::debug!("🍪 Initial cookies from login page: {cookies:?}");
82
83        // Read and parse the HTML response
84        let html = response
85            .body_string()
86            .await
87            .map_err(|e| LastFmError::Http(e.to_string()))?;
88
89        log::debug!("📄 Login page HTML length: {} chars", html.len());
90        if html.len() < 500 {
91            log::debug!("📄 Login page HTML content (short): {html}");
92        }
93
94        // Extract CSRF token and next field from form
95        let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
96        log::debug!("🔑 Extracted CSRF token: {csrf_token}",);
97        log::debug!("➡️  Next field: {next_field:?}");
98
99        Ok((csrf_token, next_field, cookies))
100    }
101
102    /// Submit the login form with credentials
103    async fn submit_login_form(
104        &self,
105        login_url: &str,
106        username: &str,
107        password: &str,
108        csrf_token: &str,
109        next_field: &Option<String>,
110        cookies: &[String],
111    ) -> Result<http_types::Response> {
112        // Prepare form data
113        let mut form_data = HashMap::new();
114        form_data.insert("csrfmiddlewaretoken", csrf_token);
115        form_data.insert("username_or_email", username);
116        form_data.insert("password", password);
117
118        if let Some(ref next_value) = next_field {
119            form_data.insert("next", next_value);
120            log::debug!("➡️  Including next field in form: {next_value}");
121        }
122
123        log::debug!(
124            "📝 Form data fields: {:?}",
125            form_data.keys().collect::<Vec<_>>()
126        );
127        log::debug!("📝 Form username: {username}");
128        log::debug!("📝 Form password length: {} chars", password.len());
129
130        // Create and configure the POST request
131        let mut request = self.create_login_request(login_url, cookies)?;
132
133        // Convert form data to URL-encoded string
134        let form_string: String = form_data
135            .iter()
136            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
137            .collect::<Vec<_>>()
138            .join("&");
139
140        log::debug!("📤 Sending POST request to: {login_url}");
141        log::debug!("📤 Form body length: {} chars", form_string.len());
142        log::debug!("📤 Form body (masked): {form_string}");
143        log::debug!("📤 Request headers: Referer={}, Origin={}, Content-Type=application/x-www-form-urlencoded", 
144            login_url, &self.base_url);
145
146        request.set_body(form_string);
147
148        // Send the request
149        let response = self
150            .client
151            .send(request)
152            .await
153            .map_err(|e| LastFmError::Http(e.to_string()))?;
154
155        log::debug!("📥 Login response status: {}", response.status());
156        log::debug!(
157            "📥 Login response headers: {:?}",
158            response.iter().collect::<Vec<_>>()
159        );
160
161        Ok(response)
162    }
163
164    /// Create and configure the login POST request with all necessary headers
165    fn create_login_request(&self, login_url: &str, cookies: &[String]) -> Result<Request> {
166        let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
167
168        // Set all the required headers
169        let _ = request.insert_header("Referer", login_url);
170        let _ = request.insert_header("Origin", &self.base_url);
171        let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
172        let _ = request.insert_header(
173            "User-Agent",
174            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
175        );
176        let _ = request.insert_header(
177            "Accept",
178            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
179        );
180        let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
181        let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
182        let _ = request.insert_header("DNT", "1");
183        let _ = request.insert_header("Connection", "keep-alive");
184        let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
185        let _ = request.insert_header(
186            "sec-ch-ua",
187            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
188        );
189        let _ = request.insert_header("sec-ch-ua-mobile", "?0");
190        let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
191        let _ = request.insert_header("Sec-Fetch-Dest", "document");
192        let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
193        let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
194        let _ = request.insert_header("Sec-Fetch-User", "?1");
195
196        // Add cookies if we have any
197        if !cookies.is_empty() {
198            let cookie_header = cookies.join("; ");
199            let _ = request.insert_header("Cookie", &cookie_header);
200        }
201
202        Ok(request)
203    }
204
205    /// Validate the login response and return a session if successful
206    async fn validate_login_response(
207        &self,
208        mut response: http_types::Response,
209        username: &str,
210        cookies: Vec<String>,
211        csrf_token: String,
212    ) -> Result<LastFmEditSession> {
213        // Handle 403 Forbidden responses (likely CSRF failures)
214        if response.status() == 403 {
215            return self.handle_403_response(response).await;
216        }
217
218        // Check for successful session establishment
219        if let Some(session) =
220            self.check_session_success(&response, username, &cookies, &csrf_token)
221        {
222            return Ok(session);
223        }
224
225        // For other cases, analyze the response body
226        let response_html = response
227            .body_string()
228            .await
229            .map_err(|e| LastFmError::Http(e.to_string()))?;
230
231        log::debug!(
232            "📄 Login response HTML length: {} chars",
233            response_html.len()
234        );
235        if response_html.len() < 500 {
236            log::debug!("📄 Login response HTML content (short): {response_html}");
237        }
238
239        // Check if we were redirected away from login page (success indicator)
240        let has_login_form = self.check_for_login_form(&response_html);
241        log::debug!("🔍 Final login validation:");
242        log::debug!("   - Response contains login form: {has_login_form}");
243        log::debug!("   - Response status: {}", response.status());
244
245        if !has_login_form && response.status() == 200 {
246            log::info!("✅ Login successful - no login form detected in response");
247            Ok(LastFmEditSession::new(
248                username.to_string(),
249                cookies,
250                Some(csrf_token),
251                self.base_url.clone(),
252            ))
253        } else {
254            // Parse and return error message
255            let error_msg = self.parse_login_error(&response_html);
256            log::warn!("❌ Login failed: {error_msg}");
257            Err(LastFmError::Auth(error_msg))
258        }
259    }
260
261    /// Handle 403 Forbidden responses
262    async fn handle_403_response(
263        &self,
264        mut response: http_types::Response,
265    ) -> Result<LastFmEditSession> {
266        let response_html = response
267            .body_string()
268            .await
269            .map_err(|e| LastFmError::Http(e.to_string()))?;
270
271        log::debug!("📄 403 response HTML length: {} chars", response_html.len());
272        if response_html.len() < 2000 {
273            log::debug!("📄 403 response HTML content: {response_html}");
274        } else {
275            // Log first and last 500 chars for large responses
276            log::debug!("📄 403 response HTML start: {}", &response_html[..500]);
277            log::debug!(
278                "📄 403 response HTML end: {}",
279                &response_html[response_html.len() - 500..]
280            );
281        }
282
283        let login_error = self.parse_login_error(&response_html);
284        Err(LastFmError::Auth(login_error))
285    }
286
287    /// Check if the response indicates successful session establishment
288    fn check_session_success(
289        &self,
290        response: &http_types::Response,
291        username: &str,
292        cookies: &[String],
293        csrf_token: &str,
294    ) -> Option<LastFmEditSession> {
295        let has_real_session = cookies
296            .iter()
297            .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
298
299        log::debug!("🔍 Session validation:");
300        log::debug!("   - Has real session cookie: {has_real_session}");
301        log::debug!("   - Response status: {}", response.status());
302        log::debug!("   - All cookies: {cookies:?}");
303
304        if has_real_session && (response.status() == 302 || response.status() == 200) {
305            log::info!("✅ Login successful - authenticated session established");
306            Some(LastFmEditSession::new(
307                username.to_string(),
308                cookies.to_vec(),
309                Some(csrf_token.to_string()),
310                self.base_url.clone(),
311            ))
312        } else {
313            None
314        }
315    }
316
317    /// Make a simple HTTP GET request (without retry logic)
318    async fn get(&self, url: &str) -> Result<http_types::Response> {
319        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
320        let _ = request.insert_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36");
321
322        self.client
323            .send(request)
324            .await
325            .map_err(|e| LastFmError::Http(e.to_string()))
326    }
327
328    /// Extract login form data (CSRF token and next field)
329    fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
330        let document = Html::parse_document(html);
331
332        let csrf_token = self.extract_csrf_token(&document)?;
333
334        // Check if there's a 'next' field in the form
335        let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
336        let next_field = document
337            .select(&next_selector)
338            .next()
339            .and_then(|input| input.value().attr("value"))
340            .map(|s| s.to_string());
341
342        Ok((csrf_token, next_field))
343    }
344
345    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
346        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
347
348        let csrf_token = document
349            .select(&csrf_selector)
350            .next()
351            .and_then(|input| input.value().attr("value"))
352            .map(|token| token.to_string())
353            .ok_or(LastFmError::CsrfNotFound)?;
354
355        log::debug!("🔑 CSRF token extracted from HTML: {csrf_token}");
356        Ok(csrf_token)
357    }
358
359    /// Parse login error messages from HTML
360    fn parse_login_error(&self, html: &str) -> String {
361        let document = Html::parse_document(html);
362
363        let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
364
365        let mut error_messages = Vec::new();
366        for error in document.select(&error_selector) {
367            let error_text = error.text().collect::<String>().trim().to_string();
368            if !error_text.is_empty() {
369                error_messages.push(error_text);
370            }
371        }
372
373        if error_messages.is_empty() {
374            "Login failed - please check your credentials".to_string()
375        } else {
376            format!("Login failed: {}", error_messages.join("; "))
377        }
378    }
379
380    /// Check if HTML contains a login form
381    fn check_for_login_form(&self, html: &str) -> bool {
382        let document = Html::parse_document(html);
383        let login_form_selector =
384            Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
385        document.select(&login_form_selector).next().is_some()
386    }
387}
388
389/// Extract cookies from HTTP response - utility function
390pub fn extract_cookies_from_response(response: &http_types::Response, cookies: &mut Vec<String>) {
391    if let Some(cookie_headers) = response.header("set-cookie") {
392        for cookie_header in cookie_headers {
393            let cookie_str = cookie_header.as_str();
394            // Extract just the cookie name=value part (before any semicolon)
395            if let Some(cookie_value) = cookie_str.split(';').next() {
396                let cookie_name = cookie_value.split('=').next().unwrap_or("");
397
398                // Remove any existing cookie with the same name
399                cookies.retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
400                cookies.push(cookie_value.to_string());
401            }
402        }
403    }
404}