arionxiv 1.0.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. arionxiv/__init__.py +40 -0
  2. arionxiv/__main__.py +10 -0
  3. arionxiv/arxiv_operations/__init__.py +0 -0
  4. arionxiv/arxiv_operations/client.py +225 -0
  5. arionxiv/arxiv_operations/fetcher.py +173 -0
  6. arionxiv/arxiv_operations/searcher.py +122 -0
  7. arionxiv/arxiv_operations/utils.py +293 -0
  8. arionxiv/cli/__init__.py +4 -0
  9. arionxiv/cli/commands/__init__.py +1 -0
  10. arionxiv/cli/commands/analyze.py +587 -0
  11. arionxiv/cli/commands/auth.py +365 -0
  12. arionxiv/cli/commands/chat.py +714 -0
  13. arionxiv/cli/commands/daily.py +482 -0
  14. arionxiv/cli/commands/fetch.py +217 -0
  15. arionxiv/cli/commands/library.py +295 -0
  16. arionxiv/cli/commands/preferences.py +426 -0
  17. arionxiv/cli/commands/search.py +254 -0
  18. arionxiv/cli/commands/settings_unified.py +1407 -0
  19. arionxiv/cli/commands/trending.py +41 -0
  20. arionxiv/cli/commands/welcome.py +168 -0
  21. arionxiv/cli/main.py +407 -0
  22. arionxiv/cli/ui/__init__.py +1 -0
  23. arionxiv/cli/ui/global_theme_manager.py +173 -0
  24. arionxiv/cli/ui/logo.py +127 -0
  25. arionxiv/cli/ui/splash.py +89 -0
  26. arionxiv/cli/ui/theme.py +32 -0
  27. arionxiv/cli/ui/theme_system.py +391 -0
  28. arionxiv/cli/utils/__init__.py +54 -0
  29. arionxiv/cli/utils/animations.py +522 -0
  30. arionxiv/cli/utils/api_client.py +583 -0
  31. arionxiv/cli/utils/api_config.py +505 -0
  32. arionxiv/cli/utils/command_suggestions.py +147 -0
  33. arionxiv/cli/utils/db_config_manager.py +254 -0
  34. arionxiv/github_actions_runner.py +206 -0
  35. arionxiv/main.py +23 -0
  36. arionxiv/prompts/__init__.py +9 -0
  37. arionxiv/prompts/prompts.py +247 -0
  38. arionxiv/rag_techniques/__init__.py +8 -0
  39. arionxiv/rag_techniques/basic_rag.py +1531 -0
  40. arionxiv/scheduler_daemon.py +139 -0
  41. arionxiv/server.py +1000 -0
  42. arionxiv/server_main.py +24 -0
  43. arionxiv/services/__init__.py +73 -0
  44. arionxiv/services/llm_client.py +30 -0
  45. arionxiv/services/llm_inference/__init__.py +58 -0
  46. arionxiv/services/llm_inference/groq_client.py +469 -0
  47. arionxiv/services/llm_inference/llm_utils.py +250 -0
  48. arionxiv/services/llm_inference/openrouter_client.py +564 -0
  49. arionxiv/services/unified_analysis_service.py +872 -0
  50. arionxiv/services/unified_auth_service.py +457 -0
  51. arionxiv/services/unified_config_service.py +456 -0
  52. arionxiv/services/unified_daily_dose_service.py +823 -0
  53. arionxiv/services/unified_database_service.py +1633 -0
  54. arionxiv/services/unified_llm_service.py +366 -0
  55. arionxiv/services/unified_paper_service.py +604 -0
  56. arionxiv/services/unified_pdf_service.py +522 -0
  57. arionxiv/services/unified_prompt_service.py +344 -0
  58. arionxiv/services/unified_scheduler_service.py +589 -0
  59. arionxiv/services/unified_user_service.py +954 -0
  60. arionxiv/utils/__init__.py +51 -0
  61. arionxiv/utils/api_helpers.py +200 -0
  62. arionxiv/utils/file_cleanup.py +150 -0
  63. arionxiv/utils/ip_helper.py +96 -0
  64. arionxiv-1.0.32.dist-info/METADATA +336 -0
  65. arionxiv-1.0.32.dist-info/RECORD +69 -0
  66. arionxiv-1.0.32.dist-info/WHEEL +5 -0
  67. arionxiv-1.0.32.dist-info/entry_points.txt +4 -0
  68. arionxiv-1.0.32.dist-info/licenses/LICENSE +21 -0
  69. arionxiv-1.0.32.dist-info/top_level.txt +1 -0
@@ -0,0 +1,457 @@
1
+ """
2
+ Unified Authentication Service for ArionXiv
3
+ Consolidates auth_service.py and auth_utils.py into a single local-auth module
4
+ """
5
+
6
+ import hashlib
7
+ import secrets
8
+ import re
9
+ import jwt
10
+ import os
11
+ import hmac
12
+ from typing import Dict, Any, Tuple, TYPE_CHECKING
13
+ from datetime import datetime, timedelta
14
+ import logging
15
+
16
+ from bson import ObjectId
17
+
18
+ from .unified_database_service import unified_database_service
19
+
20
+ # FastAPI imports - optional, only needed for server endpoints
21
+ try:
22
+ from fastapi import HTTPException, Depends
23
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
24
+ FASTAPI_AVAILABLE = True
25
+ security = HTTPBearer()
26
+ except ImportError:
27
+ FASTAPI_AVAILABLE = False
28
+ HTTPException = Exception # Fallback for raising errors
29
+ security = None
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class UnifiedAuthenticationService:
35
+ """Authentication service for local (password) accounts"""
36
+
37
+ def __init__(self):
38
+ # Password security settings
39
+ self.password_salt_length = int(os.getenv("PASSWORD_SALT_LENGTH", "32"))
40
+ self.pbkdf2_iterations = int(os.getenv("PBKDF2_ITERATIONS", "100000"))
41
+ self.password_algorithm = os.getenv("PASSWORD_HASH_ALGO", "pbkdf2_sha256")
42
+ self.min_password_length = 8
43
+ self.max_password_length = 128
44
+ self.min_user_name_length = 3
45
+ self.max_user_name_length = 32
46
+ self._user_name_pattern = re.compile(r'^[a-z0-9._-]+$')
47
+ self.session_duration_days = 30
48
+
49
+ # JWT settings - lazy loaded to allow module import without env vars
50
+ # This is needed for GitHub Actions runner which imports the module before setting env vars
51
+ self._secret_key = None
52
+ self.algorithm = "HS256"
53
+ self.token_expiry_hours = 24
54
+
55
+ logger.info("UnifiedAuthenticationService initialized")
56
+
57
+ @property
58
+ def secret_key(self) -> str:
59
+ """Lazy-load JWT secret key - only required when auth methods are actually called."""
60
+ if self._secret_key is None:
61
+ self._secret_key = os.getenv("JWT_SECRET_KEY")
62
+ if not self._secret_key:
63
+ raise ValueError("JWT_SECRET_KEY environment variable is required for security.")
64
+ return self._secret_key
65
+
66
+ # ============================================================
67
+ # PASSWORD AUTHENTICATION (from auth_service.py)
68
+ # ============================================================
69
+
70
+ def _hash_password(self, password: str, salt: bytes = None) -> Tuple[str, str]:
71
+ """
72
+ Hash password with salt
73
+
74
+ Purpose: Securely hash a plaintext password using PBKDF2 with SHA-256 and a salt.
75
+
76
+ Args:
77
+ password (str): The plaintext password to hash
78
+ salt (bytes, optional): Optional salt. If None, a new salt is generated. Defaults to None.
79
+ """
80
+ if salt is None:
81
+ salt = secrets.token_bytes(self.password_salt_length)
82
+
83
+ # Use PBKDF2 with SHA-256
84
+ password_hash = hashlib.pbkdf2_hmac(
85
+ 'sha256',
86
+ password.encode('utf-8'),
87
+ salt,
88
+ self.pbkdf2_iterations
89
+ )
90
+
91
+ return password_hash.hex(), salt.hex()
92
+
93
+ def _verify_password(self, password: str, stored_hash: str, stored_salt: str) -> bool:
94
+ """
95
+ Verify password against stored hash using the stored salt.
96
+
97
+ Args:
98
+ password (str): The plaintext password to verify
99
+ stored_hash (str): The stored password hash
100
+ stored_salt (str): The stored salt used for hashing
101
+ """
102
+ try:
103
+ salt = bytes.fromhex(stored_salt)
104
+ password_hash, _ = self._hash_password(password, salt)
105
+ return hmac.compare_digest(password_hash, stored_hash)
106
+ except Exception as e:
107
+ logger.error("Password verification failed: %s", str(e), exc_info=True)
108
+ return False
109
+
110
+ def _validate_password(self, password: str) -> Tuple[bool, str]:
111
+ """
112
+ Validate password strength
113
+
114
+ Args:
115
+ password (str): The plaintext password to validate
116
+ """
117
+
118
+ if len(password) < self.min_password_length:
119
+ return False, f"Password must be at least {self.min_password_length} characters long"
120
+
121
+ if len(password) > self.max_password_length:
122
+ return False, f"Password cannot exceed {self.max_password_length} characters"
123
+
124
+ # Check for at least one letter and one digit
125
+ if not re.search(r'[a-zA-Z]', password):
126
+ return False, "Password must contain at least one letter"
127
+
128
+ if not re.search(r'\d', password):
129
+ return False, "Password must contain at least one digit"
130
+
131
+ return True, "Password is valid"
132
+
133
+ def _validate_email(self, email: str) -> bool:
134
+ """
135
+ Validate email format
136
+
137
+ Args:
138
+ email (str): The email address to validate
139
+ """
140
+
141
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
142
+ return re.match(pattern, email) is not None
143
+
144
+ def _validate_user_name(self, user_name: str) -> Tuple[bool, str]:
145
+ """Validate user_name and return normalized value"""
146
+ if not user_name:
147
+ return False, "Username is required"
148
+ normalized = user_name.strip().lower()
149
+ if len(normalized) < self.min_user_name_length or len(normalized) > self.max_user_name_length:
150
+ return False, f"Username must be between {self.min_user_name_length} and {self.max_user_name_length} characters"
151
+ if not self._user_name_pattern.match(normalized):
152
+ return False, "Username can only contain lowercase letters, numbers, dot, underscore, or hyphen"
153
+ return True, normalized
154
+
155
+ async def register_user(self, email: str, user_name: str, password: str, full_name: str = "") -> Dict[str, Any]:
156
+ """Register a new user with user_name as the primary identifier"""
157
+ try:
158
+ if not self._validate_email(email):
159
+ return {'success': False, 'error': 'Invalid email format'}
160
+
161
+ name_valid, normalized_user_name = self._validate_user_name(user_name)
162
+ if not name_valid:
163
+ return {'success': False, 'error': normalized_user_name}
164
+
165
+ password_valid, password_message = self._validate_password(password)
166
+ if not password_valid:
167
+ return {'success': False, 'error': password_message}
168
+
169
+ # Enforce uniqueness on user_name and best-effort on email
170
+ existing_by_name = await unified_database_service.find_one('users', {'user_name': normalized_user_name})
171
+ if existing_by_name:
172
+ return {'success': False, 'error': 'Username is already taken'}
173
+
174
+ existing_by_email = await unified_database_service.find_one('users', {'email': email})
175
+ if existing_by_email:
176
+ return {'success': False, 'error': 'User with this email already exists'}
177
+
178
+ password_hash, salt = self._hash_password(password)
179
+
180
+ user_data = {
181
+ 'email': email,
182
+ 'user_name': normalized_user_name,
183
+ 'username': normalized_user_name,
184
+ 'full_name': full_name,
185
+ 'password_hash': password_hash,
186
+ 'password_salt': salt,
187
+ 'password_algo': self.password_algorithm,
188
+ 'created_at': datetime.utcnow(),
189
+ 'last_login': None,
190
+ 'is_active': True,
191
+ 'auth_provider': 'local'
192
+ }
193
+
194
+ result = await unified_database_service.insert_one('users', user_data)
195
+
196
+ if result and getattr(result, 'inserted_id', None):
197
+ user_payload = {
198
+ 'id': str(result.inserted_id),
199
+ 'email': email,
200
+ 'user_name': normalized_user_name,
201
+ 'full_name': full_name
202
+ }
203
+ return {
204
+ 'success': True,
205
+ 'message': 'User registered successfully',
206
+ 'user': user_payload
207
+ }
208
+
209
+ return {'success': False, 'error': 'Failed to create user'}
210
+
211
+ except Exception as e:
212
+ logger.error("User registration failed: %s", str(e), exc_info=True)
213
+ return {
214
+ 'success': False,
215
+ 'error': 'Registration failed due to server error'
216
+ }
217
+
218
+ async def authenticate_user(self, identifier: str, password: str) -> Dict[str, Any]:
219
+ """Authenticate user by user_name or email"""
220
+
221
+ try:
222
+ lookup_filter = None
223
+ if self._validate_email(identifier):
224
+ lookup_filter = {'email': identifier}
225
+ else:
226
+ name_valid, normalized_user_name = self._validate_user_name(identifier)
227
+ if not name_valid:
228
+ return {'success': False, 'error': 'Invalid username or password'}
229
+ lookup_filter = {'user_name': normalized_user_name}
230
+
231
+ user = await unified_database_service.find_one('users', lookup_filter)
232
+ if not user:
233
+ return {'success': False, 'error': 'Invalid username or password'}
234
+
235
+ if not user.get('is_active', True):
236
+ return {'success': False, 'error': 'Account is deactivated'}
237
+
238
+ if user.get('auth_provider') != 'local':
239
+ return {
240
+ 'success': False,
241
+ 'error': 'Please sign in with your linked provider'
242
+ }
243
+
244
+ if not self._verify_password(password, user['password_hash'], user['password_salt']):
245
+ return {'success': False, 'error': 'Invalid username or password'}
246
+
247
+ await unified_database_service.update_one(
248
+ 'users',
249
+ {'_id': user['_id']},
250
+ {'$set': {'last_login': datetime.utcnow()}}
251
+ )
252
+
253
+ token = self.create_access_token(user)
254
+ primary_user_name = user.get('user_name') or user.get('username') or user['email'].split('@')[0]
255
+ user_payload = {
256
+ 'id': str(user['_id']),
257
+ 'email': user['email'],
258
+ 'user_name': primary_user_name,
259
+ 'full_name': user.get('full_name', '')
260
+ }
261
+
262
+ return {
263
+ 'success': True,
264
+ 'message': 'Authentication successful',
265
+ 'user': user_payload,
266
+ 'token': token
267
+ }
268
+
269
+ except Exception as e:
270
+ logger.error("User authentication failed: %s", str(e), exc_info=True)
271
+ return {
272
+ 'success': False,
273
+ 'error': 'Authentication failed due to server error'
274
+ }
275
+
276
+ async def login_user(self, identifier: str, password: str) -> Dict[str, Any]:
277
+ """Compatibility wrapper for CLI login flow"""
278
+ return await self.authenticate_user(identifier, password)
279
+
280
+ # ============================================================
281
+ # JWT UTILITIES (from auth_utils.py)
282
+ # ============================================================
283
+
284
+ def create_access_token(self, user_data: Dict[str, Any]) -> str:
285
+ """
286
+ Creates JWT access token
287
+
288
+ Purpose: Generate a JWT token for authenticated users to access protected resources during the active session.
289
+
290
+ Args:
291
+ user_data (Dict[str, Any]): User data to include in the token payload
292
+
293
+ Returns:
294
+ str: Encoded JWT token
295
+ """
296
+ try:
297
+ payload = {
298
+ "user_id": str(user_data.get("_id", user_data.get("id"))),
299
+ "email": user_data.get("email"),
300
+ "user_name": user_data.get("user_name") or user_data.get("username"),
301
+ "exp": datetime.utcnow() + timedelta(hours=self.token_expiry_hours),
302
+ "iat": datetime.utcnow()
303
+ }
304
+
305
+ token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
306
+ return token
307
+
308
+ except Exception as e:
309
+ logger.error("Failed to create access token: %s", str(e), exc_info=True)
310
+ raise HTTPException(status_code=500, detail="Token creation failed")
311
+
312
+ def verify_token(self, token: str) -> Dict[str, Any]:
313
+ """
314
+ Verifies JWT token and return payload
315
+
316
+ Args:
317
+ token (str): JWT token to verify
318
+
319
+ Returns:
320
+ Dict[str, Any]: Verification result with validity and payload or error message
321
+ """
322
+ try:
323
+ payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
324
+ return {"valid": True, "payload": payload}
325
+
326
+ except jwt.ExpiredSignatureError:
327
+ return {"valid": False, "error": "Token has expired"}
328
+ except jwt.InvalidTokenError as e:
329
+ return {"valid": False, "error": f"Invalid token: {str(e)}"}
330
+
331
+ async def get_current_user(self, credentials) -> Dict[str, Any]:
332
+ """
333
+ Get current user from token
334
+
335
+ Args:
336
+ credentials: Authorization credentials containing the JWT token
337
+
338
+ Returns:
339
+ Dict[str, Any]: Current user information extracted from the token
340
+ """
341
+ result = self.verify_token(credentials.credentials)
342
+
343
+ if not result["valid"]:
344
+ raise HTTPException(status_code=401, detail=result["error"])
345
+
346
+ user_id = result["payload"]["user_id"]
347
+
348
+ # Get user from database
349
+ try:
350
+ user = await unified_database_service.find_one('users', {'_id': ObjectId(user_id)})
351
+ if not user:
352
+ raise HTTPException(status_code=401, detail="User not found")
353
+
354
+ return {
355
+ "id": str(user["_id"]),
356
+ "email": user["email"],
357
+ "user_name": user.get("user_name") or user.get("username") or user["email"].split("@")[0]
358
+ }
359
+
360
+ except Exception as e:
361
+ logger.error("Failed to get current user: %s", str(e), exc_info=True)
362
+ raise HTTPException(status_code=401, detail="Authentication failed")
363
+
364
+ async def get_user_settings(self, user_id: str) -> Dict[str, Any]:
365
+ """Get user settings from database"""
366
+ try:
367
+ user_object_id = ObjectId(user_id)
368
+ user = await unified_database_service.find_one('users', {'_id': user_object_id})
369
+ if not user:
370
+ return {
371
+ 'success': False,
372
+ 'error': 'User not found'
373
+ }
374
+
375
+ return {
376
+ 'success': True,
377
+ 'settings': user.get('settings', {})
378
+ }
379
+ except Exception as e:
380
+ logger.error("Failed to get user settings: %s", str(e), exc_info=True)
381
+ return {
382
+ 'success': False,
383
+ 'error': 'Failed to retrieve user settings'
384
+ }
385
+
386
+ async def update_user_settings(self, user_id: str, settings: Dict[str, Any]) -> Dict[str, Any]:
387
+ """Update user settings in database"""
388
+ try:
389
+ user_object_id = ObjectId(user_id)
390
+ result = await unified_database_service.update_one(
391
+ 'users',
392
+ {'_id': user_object_id},
393
+ {'$set': {'settings': settings}}
394
+ )
395
+
396
+ # Check if document was matched (user exists)
397
+ matched_count = getattr(result, 'matched_count', None)
398
+ if matched_count is None and isinstance(result, dict):
399
+ matched_count = result.get('matched_count', 0)
400
+
401
+ modified_count = getattr(result, 'modified_count', None)
402
+ if modified_count is None and isinstance(result, dict):
403
+ modified_count = result.get('modified_count', 0)
404
+
405
+ # Success if document was found (matched), even if no changes (same values)
406
+ if matched_count and matched_count > 0:
407
+ return {
408
+ 'success': True,
409
+ 'message': 'Settings updated successfully'
410
+ }
411
+ else:
412
+ return {
413
+ 'success': False,
414
+ 'error': 'User not found'
415
+ }
416
+ except Exception as e:
417
+ logger.error("Failed to update user settings: %s", str(e), exc_info=True)
418
+ return {
419
+ 'success': False,
420
+ 'error': 'Failed to update user settings'
421
+ }
422
+
423
+
424
+ # Global instance
425
+ unified_auth_service = UnifiedAuthenticationService()
426
+
427
+ # Backwards compatibility
428
+ auth_service = unified_auth_service
429
+ auth_utils = unified_auth_service
430
+
431
+ # Export commonly used functions for convenience
432
+ create_access_token = unified_auth_service.create_access_token
433
+ verify_token = unified_auth_service.verify_token
434
+ get_current_user = unified_auth_service.get_current_user
435
+ register_user = unified_auth_service.register_user
436
+ authenticate_user = unified_auth_service.authenticate_user
437
+ login_user = unified_auth_service.login_user
438
+ get_user_settings = unified_auth_service.get_user_settings
439
+ update_user_settings = unified_auth_service.update_user_settings
440
+
441
+ # Make available for imports
442
+ __all__ = [
443
+ 'UnifiedAuthenticationService',
444
+ 'unified_auth_service',
445
+ 'auth_service',
446
+ 'auth_utils',
447
+ 'create_access_token',
448
+ 'verify_token',
449
+ 'get_current_user',
450
+ 'register_user',
451
+ 'authenticate_user',
452
+ 'login_user',
453
+ 'get_user_settings',
454
+ 'update_user_settings',
455
+ 'security',
456
+ 'FASTAPI_AVAILABLE'
457
+ ]