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}