Skip to main content

lastfm_edit/
session_persistence.rs

1use crate::types::{LastFmEditSession, LastFmError};
2use crate::Result;
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::PathBuf;
6
7#[cfg(unix)]
8use std::os::unix::fs::OpenOptionsExt;
9
10/// Configurable session manager for storing session data in XDG directories.
11///
12/// This struct allows customization of the application prefix for session storage.
13/// Sessions are stored per-user in the format:
14/// `~/.local/share/{app_name}/users/{username}/session.json`
15#[derive(Clone, Debug)]
16pub struct SessionManager {
17    app_name: String,
18    data_dir: Option<PathBuf>,
19}
20
21impl SessionManager {
22    /// Create a new session manager with a custom application name.
23    ///
24    /// # Arguments
25    /// * `app_name` - The application name to use as the directory prefix
26    pub fn new(app_name: impl Into<String>) -> Self {
27        Self {
28            app_name: app_name.into(),
29            data_dir: None,
30        }
31    }
32
33    /// Create a session manager rooted in an explicit application data directory.
34    ///
35    /// This is useful on platforms such as Android where the process does not have
36    /// an XDG home, but the application has a private files directory.
37    pub fn with_data_dir(app_name: impl Into<String>, data_dir: impl Into<PathBuf>) -> Self {
38        Self {
39            app_name: app_name.into(),
40            data_dir: Some(data_dir.into()),
41        }
42    }
43
44    fn data_dir(&self) -> Result<PathBuf> {
45        self.data_dir
46            .clone()
47            .or_else(dirs::data_dir)
48            .ok_or_else(|| {
49                LastFmError::Http("Cannot determine application data directory".to_string())
50            })
51    }
52
53    /// Get the session file path for a given username using the configured app name.
54    ///
55    /// Returns a path like: `~/.local/share/{app_name}/users/{username}/session.json`
56    ///
57    /// # Arguments
58    /// * `username` - The Last.fm username
59    ///
60    /// # Returns
61    /// Returns the path where the session should be stored, or an error if
62    /// the XDG data directory cannot be determined.
63    pub fn get_session_path(&self, username: &str) -> Result<PathBuf> {
64        let data_dir = self.data_dir()?;
65
66        let session_dir = data_dir.join(&self.app_name).join("users").join(username);
67
68        Ok(session_dir.join("session.json"))
69    }
70
71    /// Save a session to the XDG data directory.
72    ///
73    /// This creates the necessary directory structure and saves the session
74    /// as JSON to `~/.local/share/{app_name}/users/{username}/session.json`
75    ///
76    /// # Arguments
77    /// * `session` - The session to save
78    ///
79    /// # Returns
80    /// Returns Ok(()) on success, or an error if the save fails.
81    pub fn save_session(&self, session: &LastFmEditSession) -> Result<()> {
82        let session_path = self.get_session_path(&session.username)?;
83
84        // Create parent directories if they don't exist
85        if let Some(parent) = session_path.parent() {
86            fs::create_dir_all(parent).map_err(|e| {
87                LastFmError::Http(format!("Failed to create session directory: {e}"))
88            })?;
89        }
90
91        // Serialize session to JSON
92        let session_json = session
93            .to_json()
94            .map_err(|e| LastFmError::Http(format!("Failed to serialize session: {e}")))?;
95
96        // Session cookies grant account access, so never create a world-readable file.
97        let mut options = OpenOptions::new();
98        options.write(true).create(true).truncate(true);
99        #[cfg(unix)]
100        options.mode(0o600);
101        let mut file = options
102            .open(&session_path)
103            .map_err(|e| LastFmError::Http(format!("Failed to open session file: {e}")))?;
104        file.write_all(session_json.as_bytes())
105            .map_err(|e| LastFmError::Http(format!("Failed to write session file: {e}")))?;
106
107        #[cfg(unix)]
108        {
109            use std::os::unix::fs::PermissionsExt;
110            file.set_permissions(fs::Permissions::from_mode(0o600))
111                .map_err(|e| LastFmError::Http(format!("Failed to protect session file: {e}")))?;
112        }
113
114        log::debug!("Session saved to: {}", session_path.display());
115        Ok(())
116    }
117
118    /// Load a session from the XDG data directory.
119    ///
120    /// Attempts to load a session from `~/.local/share/{app_name}/users/{username}/session.json`
121    ///
122    /// # Arguments
123    /// * `username` - The Last.fm username
124    ///
125    /// # Returns
126    /// Returns the loaded session on success, or an error if the file doesn't exist
127    /// or cannot be parsed.
128    pub fn load_session(&self, username: &str) -> Result<LastFmEditSession> {
129        let session_path = self.get_session_path(username)?;
130
131        if !session_path.exists() {
132            return Err(LastFmError::Http(format!(
133                "No saved session found for user: {username}"
134            )));
135        }
136
137        // Read and parse session file
138        let session_json = fs::read_to_string(&session_path)
139            .map_err(|e| LastFmError::Http(format!("Failed to read session file: {e}")))?;
140
141        let session = LastFmEditSession::from_json(&session_json)
142            .map_err(|e| LastFmError::Http(format!("Failed to parse session JSON: {e}")))?;
143
144        log::debug!("Session loaded from: {}", session_path.display());
145        Ok(session)
146    }
147
148    /// Check if a saved session exists for the given username.
149    ///
150    /// # Arguments
151    /// * `username` - The Last.fm username
152    ///
153    /// # Returns
154    /// Returns true if a session file exists, false otherwise.
155    pub fn session_exists(&self, username: &str) -> bool {
156        match self.get_session_path(username) {
157            Ok(path) => path.exists(),
158            Err(_) => false,
159        }
160    }
161
162    /// Remove a saved session for the given username.
163    ///
164    /// This deletes the session file from the XDG data directory.
165    ///
166    /// # Arguments
167    /// * `username` - The Last.fm username
168    ///
169    /// # Returns
170    /// Returns Ok(()) on success, or an error if the deletion fails.
171    pub fn remove_session(&self, username: &str) -> Result<()> {
172        let session_path = self.get_session_path(username)?;
173
174        if session_path.exists() {
175            fs::remove_file(&session_path)
176                .map_err(|e| LastFmError::Http(format!("Failed to remove session file: {e}")))?;
177            log::debug!("Session removed from: {}", session_path.display());
178        }
179
180        Ok(())
181    }
182
183    /// List all usernames that have saved sessions.
184    ///
185    /// Scans the XDG data directory for session files and returns the usernames.
186    ///
187    /// # Returns
188    /// Returns a vector of usernames that have saved sessions.
189    pub fn list_saved_users(&self) -> Result<Vec<String>> {
190        let data_dir = self.data_dir()?;
191
192        let users_dir = data_dir.join(&self.app_name).join("users");
193
194        if !users_dir.exists() {
195            return Ok(Vec::new());
196        }
197
198        let mut users = Vec::new();
199        let entries = fs::read_dir(&users_dir)
200            .map_err(|e| LastFmError::Http(format!("Failed to read users directory: {e}")))?;
201
202        for entry in entries {
203            let entry = entry
204                .map_err(|e| LastFmError::Http(format!("Failed to read directory entry: {e}")))?;
205
206            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
207                let session_file = entry.path().join("session.json");
208                if session_file.exists() {
209                    if let Some(username) = entry.file_name().to_str() {
210                        users.push(username.to_string());
211                    }
212                }
213            }
214        }
215
216        Ok(users)
217    }
218
219    /// Get the application name used by this session manager.
220    pub fn app_name(&self) -> &str {
221        &self.app_name
222    }
223}
224
225/// Session persistence utilities for managing session data in XDG directories.
226///
227/// This module provides functionality to save and load Last.fm session data
228/// using the XDG Base Directory Specification. Sessions are stored per-user
229/// in the format: `~/.local/share/lastfm-edit/users/{username}/session.json`
230///
231/// # Deprecated
232/// Use [`SessionManager`] instead for more flexibility and customization.
233pub struct SessionPersistence;
234
235impl SessionPersistence {
236    /// Get the default session manager for lastfm-edit.
237    fn default_manager() -> SessionManager {
238        SessionManager::new("lastfm-edit")
239    }
240
241    /// Get the session file path for a given username using XDG directories.
242    ///
243    /// Returns a path like: `~/.local/share/lastfm-edit/users/{username}/session.json`
244    ///
245    /// # Arguments
246    /// * `username` - The Last.fm username
247    ///
248    /// # Returns
249    /// Returns the path where the session should be stored, or an error if
250    /// the XDG data directory cannot be determined.
251    pub fn get_session_path(username: &str) -> Result<PathBuf> {
252        Self::default_manager().get_session_path(username)
253    }
254
255    /// Save a session to the XDG data directory.
256    ///
257    /// This creates the necessary directory structure and saves the session
258    /// as JSON to `~/.local/share/lastfm-edit/users/{username}/session.json`
259    ///
260    /// # Arguments
261    /// * `session` - The session to save
262    ///
263    /// # Returns
264    /// Returns Ok(()) on success, or an error if the save fails.
265    pub fn save_session(session: &LastFmEditSession) -> Result<()> {
266        Self::default_manager().save_session(session)
267    }
268
269    /// Load a session from the XDG data directory.
270    ///
271    /// Attempts to load a session from `~/.local/share/lastfm-edit/users/{username}/session.json`
272    ///
273    /// # Arguments
274    /// * `username` - The Last.fm username
275    ///
276    /// # Returns
277    /// Returns the loaded session on success, or an error if the file doesn't exist
278    /// or cannot be parsed.
279    pub fn load_session(username: &str) -> Result<LastFmEditSession> {
280        Self::default_manager().load_session(username)
281    }
282
283    /// Check if a saved session exists for the given username.
284    ///
285    /// # Arguments
286    /// * `username` - The Last.fm username
287    ///
288    /// # Returns
289    /// Returns true if a session file exists, false otherwise.
290    pub fn session_exists(username: &str) -> bool {
291        Self::default_manager().session_exists(username)
292    }
293
294    /// Remove a saved session for the given username.
295    ///
296    /// This deletes the session file from the XDG data directory.
297    ///
298    /// # Arguments
299    /// * `username` - The Last.fm username
300    ///
301    /// # Returns
302    /// Returns Ok(()) on success, or an error if the deletion fails.
303    pub fn remove_session(username: &str) -> Result<()> {
304        Self::default_manager().remove_session(username)
305    }
306
307    /// List all usernames that have saved sessions.
308    ///
309    /// Scans the XDG data directory for session files and returns the usernames.
310    ///
311    /// # Returns
312    /// Returns a vector of usernames that have saved sessions.
313    pub fn list_saved_users() -> Result<Vec<String>> {
314        Self::default_manager().list_saved_users()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_session_path_generation() {
324        let path = SessionPersistence::get_session_path("testuser").unwrap();
325        assert!(path
326            .to_string_lossy()
327            .contains("lastfm-edit/users/testuser/session.json"));
328    }
329
330    #[test]
331    fn test_session_exists_nonexistent() {
332        let fake_username = format!("nonexistent_user_{}", std::process::id());
333        assert!(!SessionPersistence::session_exists(&fake_username));
334    }
335
336    #[test]
337    fn explicit_data_dir_keeps_sessions_under_the_app_private_root() {
338        let private_files = PathBuf::from("/data/user/0/org.example.app/files");
339        let manager = SessionManager::with_data_dir("lastfm-edit", &private_files);
340
341        assert_eq!(
342            manager.get_session_path("mobile-user").unwrap(),
343            private_files.join("lastfm-edit/users/mobile-user/session.json")
344        );
345    }
346}