lastfm_edit/
login.rs

1use crate::session::LastFmEditSession;
2use crate::{LastFmError, 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        // Get login page to extract CSRF token
38        let login_url = format!("{}/login", self.base_url);
39        let mut response = self.get(&login_url).await?;
40
41        // Extract any initial cookies from the login page
42        let mut cookies = Vec::new();
43        extract_cookies_from_response(&response, &mut cookies);
44
45        let html = response
46            .body_string()
47            .await
48            .map_err(|e| LastFmError::Http(e.to_string()))?;
49
50        // Parse HTML to extract login form data
51        let (csrf_token, next_field) = self.extract_login_form_data(&html)?;
52
53        // Submit login form
54        let mut form_data = HashMap::new();
55        form_data.insert("csrfmiddlewaretoken", csrf_token.as_str());
56        form_data.insert("username_or_email", username);
57        form_data.insert("password", password);
58
59        // Add 'next' field if present
60        if let Some(ref next_value) = next_field {
61            form_data.insert("next", next_value);
62        }
63
64        let mut request = Request::new(Method::Post, login_url.parse::<Url>().unwrap());
65        let _ = request.insert_header("Referer", &login_url);
66        let _ = request.insert_header("Origin", &self.base_url);
67        let _ = request.insert_header("Content-Type", "application/x-www-form-urlencoded");
68        let _ = request.insert_header(
69            "User-Agent",
70            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
71        );
72        let _ = request.insert_header(
73            "Accept",
74            "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"
75        );
76        let _ = request.insert_header("Accept-Language", "en-US,en;q=0.9");
77        let _ = request.insert_header("Accept-Encoding", "gzip, deflate, br");
78        let _ = request.insert_header("DNT", "1");
79        let _ = request.insert_header("Connection", "keep-alive");
80        let _ = request.insert_header("Upgrade-Insecure-Requests", "1");
81        let _ = request.insert_header(
82            "sec-ch-ua",
83            "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
84        );
85        let _ = request.insert_header("sec-ch-ua-mobile", "?0");
86        let _ = request.insert_header("sec-ch-ua-platform", "\"Linux\"");
87        let _ = request.insert_header("Sec-Fetch-Dest", "document");
88        let _ = request.insert_header("Sec-Fetch-Mode", "navigate");
89        let _ = request.insert_header("Sec-Fetch-Site", "same-origin");
90        let _ = request.insert_header("Sec-Fetch-User", "?1");
91
92        // Add any cookies we already have
93        if !cookies.is_empty() {
94            let cookie_header = cookies.join("; ");
95            let _ = request.insert_header("Cookie", &cookie_header);
96        }
97
98        // Convert form data to URL-encoded string
99        let form_string: String = form_data
100            .iter()
101            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
102            .collect::<Vec<_>>()
103            .join("&");
104
105        request.set_body(form_string);
106
107        let mut response = self
108            .client
109            .send(request)
110            .await
111            .map_err(|e| LastFmError::Http(e.to_string()))?;
112
113        // Extract session cookies from login response
114        extract_cookies_from_response(&response, &mut cookies);
115
116        log::debug!("Login response status: {}", response.status());
117
118        // If we get a 403, it's likely an auth failure
119        if response.status() == 403 {
120            let response_html = response
121                .body_string()
122                .await
123                .map_err(|e| LastFmError::Http(e.to_string()))?;
124
125            let login_error = self.parse_login_error(&response_html);
126            return Err(LastFmError::Auth(login_error));
127        }
128
129        // Check if we got a new sessionid that looks like a real Last.fm session
130        let has_real_session = cookies
131            .iter()
132            .any(|cookie| cookie.starts_with("sessionid=.") && cookie.len() > 50);
133
134        if has_real_session && (response.status() == 302 || response.status() == 200) {
135            // We got a real session ID, login was successful
136            log::debug!("Login successful - authenticated session established");
137            return Ok(LastFmEditSession::new(
138                username.to_string(),
139                cookies,
140                Some(csrf_token),
141                self.base_url.clone(),
142            ));
143        }
144
145        // At this point, we didn't get a 403, so read the response body for other cases
146        let response_html = response
147            .body_string()
148            .await
149            .map_err(|e| LastFmError::Http(e.to_string()))?;
150
151        // Check if we were redirected away from login page (success) by parsing
152        let has_login_form = self.check_for_login_form(&response_html);
153
154        if !has_login_form && response.status() == 200 {
155            Ok(LastFmEditSession::new(
156                username.to_string(),
157                cookies,
158                Some(csrf_token),
159                self.base_url.clone(),
160            ))
161        } else {
162            // Parse error messages
163            let error_msg = self.parse_login_error(&response_html);
164            Err(LastFmError::Auth(error_msg))
165        }
166    }
167
168    /// Make a simple HTTP GET request (without retry logic)
169    async fn get(&self, url: &str) -> Result<http_types::Response> {
170        let mut request = Request::new(Method::Get, url.parse::<Url>().unwrap());
171        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");
172
173        self.client
174            .send(request)
175            .await
176            .map_err(|e| LastFmError::Http(e.to_string()))
177    }
178
179    /// Extract login form data (CSRF token and next field)
180    fn extract_login_form_data(&self, html: &str) -> Result<(String, Option<String>)> {
181        let document = Html::parse_document(html);
182
183        let csrf_token = self.extract_csrf_token(&document)?;
184
185        // Check if there's a 'next' field in the form
186        let next_selector = Selector::parse("input[name=\"next\"]").unwrap();
187        let next_field = document
188            .select(&next_selector)
189            .next()
190            .and_then(|input| input.value().attr("value"))
191            .map(|s| s.to_string());
192
193        Ok((csrf_token, next_field))
194    }
195
196    fn extract_csrf_token(&self, document: &Html) -> Result<String> {
197        let csrf_selector = Selector::parse("input[name=\"csrfmiddlewaretoken\"]").unwrap();
198
199        document
200            .select(&csrf_selector)
201            .next()
202            .and_then(|input| input.value().attr("value"))
203            .map(|token| token.to_string())
204            .ok_or(LastFmError::CsrfNotFound)
205    }
206
207    /// Parse login error messages from HTML
208    fn parse_login_error(&self, html: &str) -> String {
209        let document = Html::parse_document(html);
210
211        let error_selector = Selector::parse(".alert-danger, .form-error, .error-message").unwrap();
212
213        let mut error_messages = Vec::new();
214        for error in document.select(&error_selector) {
215            let error_text = error.text().collect::<String>().trim().to_string();
216            if !error_text.is_empty() {
217                error_messages.push(error_text);
218            }
219        }
220
221        if error_messages.is_empty() {
222            "Login failed - please check your credentials".to_string()
223        } else {
224            format!("Login failed: {}", error_messages.join("; "))
225        }
226    }
227
228    /// Check if HTML contains a login form
229    fn check_for_login_form(&self, html: &str) -> bool {
230        let document = Html::parse_document(html);
231        let login_form_selector =
232            Selector::parse("form[action*=\"login\"], input[name=\"username_or_email\"]").unwrap();
233        document.select(&login_form_selector).next().is_some()
234    }
235}
236
237/// Extract cookies from HTTP response - utility function
238pub fn extract_cookies_from_response(response: &http_types::Response, cookies: &mut Vec<String>) {
239    if let Some(cookie_headers) = response.header("set-cookie") {
240        for cookie_header in cookie_headers {
241            let cookie_str = cookie_header.as_str();
242            // Extract just the cookie name=value part (before any semicolon)
243            if let Some(cookie_value) = cookie_str.split(';').next() {
244                let cookie_name = cookie_value.split('=').next().unwrap_or("");
245
246                // Remove any existing cookie with the same name
247                cookies.retain(|existing| !existing.starts_with(&format!("{cookie_name}=")));
248                cookies.push(cookie_value.to_string());
249            }
250        }
251    }
252}