kapsikkum-unmanic – Blame information for rev 1

Subversion Repositories:
Rev:
Rev Author Line No. Line
1 office 1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3  
4 """
5 unmanic.session.py
6  
7 Written by: Josh.5 <jsunnex@gmail.com>
8 Date: 10 Mar 2021, (5:20 PM)
9  
10 Copyright:
11 Copyright (C) Josh Sunnex - All Rights Reserved
12  
13 Permission is hereby granted, free of charge, to any person obtaining a copy
14 of this software and associated documentation files (the "Software"), to deal
15 in the Software without restriction, including without limitation the rights
16 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 copies of the Software, and to permit persons to whom the Software is
18 furnished to do so, subject to the following conditions:
19  
20 The above copyright notice and this permission notice shall be included in all
21 copies or substantial portions of the Software.
22  
23 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
24 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
25 MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
26 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
27 DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
28 OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
29 OR OTHER DEALINGS IN THE SOFTWARE.
30  
31 """
32 import base64
33 import pickle
34 import random
35 import time
36 import requests
37  
38 from unmanic import config
39 from unmanic.libs import common, unlogger
40 from unmanic.libs.singleton import SingletonType
41 from unmanic.libs.unmodels import Installation
42  
43  
44 class Session(object, metaclass=SingletonType):
45 """
46 Session
47  
48 Manages the Unbmanic applications session for unlocking
49 features and fetching data from the Unmanic site API.
50  
51 """
52  
53 """
54 level - The user auth level
55 Set level to 0 by default
56 """
57 level = 5
58  
59 """
60 non supporter library count
61 """
62 library_count = 1000
63  
64 """
65 non supporter linked installations count
66 """
67 link_count = 1000
68  
69 """
70 picture_uri - The user avatar
71 """
72 picture_uri = ''
73  
74 """
75 name - The user's name
76 """
77 name = ''
78  
79 """
80 email - The user's email
81 """
82 email = ''
83  
84 """
85 created - The timestamp when the session was created
86 """
87 created = None
88  
89 """
90 last_check - The timestamp when the session was last checked
91 """
92 last_check = None
93  
94 """
95 uuid - This installation's UUID
96 """
97 uuid = None
98  
99 """
100 user_access_token - The access token to authenticate requests with the Unmanic API
101 """
102 user_access_token = None
103  
104 """
105 session_cookies - A stored copy of the session cookies to persist between restarts
106 """
107 session_cookies = None
108  
109 def __init__(self, *args, **kwargs):
110 unmanic_logging = unlogger.UnmanicLogger.__call__()
111 self.logger = unmanic_logging.get_logger(__class__.__name__)
112 self.timeout = 30
113 self.dev_local_api = kwargs.get('dev_local_api', False)
114 self.requests_session = requests.Session()
115 self.logger.info('Initialising new session object')
116  
117 def __created_older_than_x_days(self, days=1):
118 # (86400 = 24 hours)
119 seconds = (days * 86400)
120 # Get session expiration time
121 time_now = time.time()
122 time_when_session_expires = self.created + seconds
123 # Check that the time create is less than 24 hours old
124 if time_now < time_when_session_expires:
125 return False
126 return True
127  
128 def __check_session_valid(self):
129 """
130 Ensure that the session is valid.
131 A session is only valid for a limited amount of time.
132 After a session expires, it should be re-acquired.
133  
134 :return:
135 """
136 # Last checked less than a min ago... just keep the current session.
137 # This check is only to prevent spamming requests when the site API is unreachable
138 # Only check in every 40 mins (2400s) minimum. Never ignore a checkin for more than 45 mins (2700s)
139 ### if self.last_check and 2700 > (time.time() - self.last_check) < 2400:
140 if self.last_check and 45 > (time.time() - self.last_check) < 40:
141 return True
142 # If the session has never been created, return false
143 if not self.created:
144 return False
145 # Check if the time the session was created is less than 1 day old
146 if not self.__created_older_than_x_days(days=2):
147 # Only try to recreate the session once a day
148 return True
149 self.logger.debug('Session no longer valid')
150 return False
151  
152 def __update_created_timestamp(self):
153 """
154 Update the session "created" timestamp.
155  
156 :return:
157 """
158 # Get the time now in seconds
159 seconds = time.time()
160 # Create a seconds offset of some random number between 300 (5 mins) and 900 (15 mins)
161 seconds_offset = random.randint(300, 900 - 1)
162 # Set the created flag with the seconds variable plus a random offset to avoid people joining
163 # together to register if the site goes down
164 self.created = (seconds + seconds_offset)
165 # Print only the accurate update time in debug log
166 from datetime import datetime
167 created = datetime.fromtimestamp(seconds)
168 self.logger.debug('Updated session at %s', str(created))
169  
170 def __fetch_installation_data(self):
171 """
172 Fetch installation data from DB
173  
174 :return:
175 """
176 # Fetch installation
177 db_installation = Installation()
178 try:
179 # Fetch a single row (get() will raise DoesNotExist exception if no results are found)
180 current_installation = db_installation.select().order_by(Installation.id.asc()).limit(1).get()
181 except Exception as e:
182 # Create settings (defaults will be applied)
183 self.logger.debug('Unmanic session does not yet exist... Creating.')
184 db_installation.delete().execute()
185 current_installation = db_installation.create()
186  
187 self.uuid = str(current_installation.uuid)
188 self.level = int(current_installation.level)
189 self.picture_uri = str(current_installation.picture_uri)
190 self.name = str(current_installation.name)
191 self.email = str(current_installation.email)
192 self.created = current_installation.created
193  
194 self.__update_session_auth(access_token=current_installation.user_access_token,
195 session_cookies=current_installation.session_cookies)
196  
197 def __store_installation_data(self):
198 """
199 Store installation data in DB to persist reboot
200  
201 :return:
202 """
203 if self.uuid:
204 db_installation = Installation.get_or_none(uuid=self.uuid)
205 db_installation.level = self.level
206 db_installation.picture_uri = self.picture_uri
207 db_installation.name = self.name
208 db_installation.email = self.email
209 db_installation.created = self.created
210 if self.user_access_token:
211 db_installation.user_access_token = self.user_access_token
212 if self.session_cookies:
213 db_installation.session_cookies = self.session_cookies
214 db_installation.save()
215  
216 def __reset_session_installation_data(self):
217 """
218 Reset stored session data
219  
220 :return:
221 """
222 self.logger.debug('Resetting session installation data.')
223 self.level = 0
224 self.picture_uri = ''
225 self.name = ''
226 self.email = ''
227 self.created = time.time()
228 self.user_access_token = None
229 self.__store_installation_data()
230  
231 def __update_session_auth(self, access_token=None, session_cookies=None):
232 # Update session headers
233 if access_token:
234 self.user_access_token = access_token
235 self.requests_session.headers.update({'Authorization': self.user_access_token})
236 # Update session cookies
237 if session_cookies:
238 self.session_cookies = session_cookies
239 try:
240 self.requests_session.cookies = pickle.loads(base64.b64decode(session_cookies))
241 except Exception as e:
242 self.logger.error('Error trying to reload session cookies - %s', str(e))
243  
244 def get_installation_uuid(self):
245 """
246 Returns the installation UUID as a string.
247 If it does not yet exist, it will create one.
248  
249 :return:
250 """
251 if not self.uuid:
252 self.__fetch_installation_data()
253 return self.uuid
254  
255 def get_supporter_level(self):
256 """
257 Returns the supporter level
258  
259 :return:
260 """
261 if not self.level:
262 self.__fetch_installation_data()
263 return self.level
264  
265 def get_site_url(self):
266 """
267 Set the Unmanic application site URL
268 :return:
269 """
270 api_proto = "https"
271 api_domain = "api.unmanic.app"
272 if self.dev_local_api:
273 api_proto = "http"
274 api_domain = "api.unmanic.localhost"
275 return "{0}://{1}".format(api_proto, api_domain)
276  
277 def set_full_api_url(self, api_prefix, api_version, api_path):
278 """
279 Set the API path URL
280  
281 :param api_prefix:
282 :param api_version:
283 :param api_path:
284 :return:
285 """
286 api_versioned_path = "{}/v{}".format(api_prefix, api_version)
287 return "{0}/{1}/{2}".format(self.get_site_url(), api_versioned_path, api_path)
288  
289 def api_get(self, api_prefix, api_version, api_path):
290 """
291 Generate and execute a GET API call.
292  
293 :param api_prefix:
294 :param api_version:
295 :param api_path:
296 :return:
297 """
298 u = self.set_full_api_url(api_prefix, api_version, api_path)
299 r = self.requests_session.get(u, timeout=self.timeout)
300 if r.status_code > 403:
301 # There is an issue with the remote API
302 self.logger.debug(
303 "Sorry! There seems to be an issue with the remote servers. Please try GET request again later. Status code %s",
304 r.status_code)
305 if r.status_code == 401:
306 # Verify the token. Refresh as required
307 token_verified = self.verify_token()
308 # If successful, then retry request
309 if token_verified:
310 r = self.requests_session.get(u, timeout=self.timeout)
311 if r.status_code > 403:
312 # There is an issue with the remote API
313 self.logger.debug(
314 "Sorry! There seems to be an issue with the remote servers on retry. Please try GET request again later. Status code %s",
315 r.status_code)
316 else:
317 self.logger.debug('Failed to verify auth (api_get)')
318 return r.json(), r.status_code
319  
320 def api_post(self, api_prefix, api_version, api_path, data):
321 """
322 Generate and execute a POST API call.
323  
324 :param api_prefix:
325 :param api_version:
326 :param api_path:
327 :param data:
328 :return:
329 """
330 u = self.set_full_api_url(api_prefix, api_version, api_path)
331 r = self.requests_session.post(u, json=data, timeout=self.timeout)
332 if r.status_code > 403:
333 # There is an issue with the remote API
334 self.logger.debug(
335 "Sorry! There seems to be an issue with the remote servers. Please try POST request again later. Status code %s",
336 r.status_code)
337 if r.status_code == 401:
338 # Verify the token. Refresh as required
339 token_verified = self.verify_token()
340 # If successful, then retry request
341 if token_verified:
342 r = self.requests_session.post(u, json=data, timeout=self.timeout)
343 if r.status_code > 403:
344 # There is an issue with the remote API
345 self.logger.debug(
346 "Sorry! There seems to be an issue with the remote servers on retry. Please try POST request again later. Status code %s",
347 r.status_code)
348 else:
349 self.logger.debug('Failed to verify auth (api_post)')
350 return r.json(), r.status_code
351  
352 def verify_token(self):
353 if not self.user_access_token:
354 # No valid tokens exist
355 return False
356 # Check if access token is valid
357 u = self.set_full_api_url('support-auth-api', 1, 'user_auth/verify_token')
358 r = self.requests_session.get(u, timeout=self.timeout)
359 if r.status_code in [202]:
360 # Token is valid
361 return True
362 elif r.status_code > 403:
363 # Issue with server... Just carry on with current access token can't fix that here.
364 self.logger.debug(
365 "Sorry! There seems to be an issue with the token auth servers. Please try again later. Status code %s",
366 r.status_code)
367 # Return True here to prevent the app from lowering the level
368 return True
369  
370 # Access token is not valid. Refresh it.
371 self.logger.debug('Unable to verify authentication token. Refreshing...')
372 u = self.set_full_api_url('support-auth-api', 1, 'user_auth/refresh_token')
373 r = self.requests_session.get(u, timeout=self.timeout)
374 if r.status_code in [202]:
375 # Token refreshed
376 # Store the updated access token
377 response = r.json()
378 self.__update_session_auth(access_token=response.get('data', {}).get('accessToken'))
379 # Store the updated session cookies
380 self.session_cookies = base64.b64encode(pickle.dumps(self.requests_session.cookies)).decode('utf-8')
381 self.__store_installation_data()
382 return True
383 elif r.status_code > 403:
384 # Issue was with server... Just carry on with current access token can't fix that here.
385 self.logger.debug(
386 "Sorry! There seems to be an issue with the auth servers. Please try again later. Status code %s",
387 r.status_code)
388 # Return True here to prevent the app from lowering the level
389 return True
390 elif r.status_code in [403]:
391 # Log this failure in the debug logs
392 self.logger.debug('Failed to refresh access token.')
393 response = r.json()
394 for message in response.get('messages', []):
395 self.logger.debug(message)
396 # Just blank the class attribute.
397 # It is fine for requests to be sent with further requests.
398 # User will appear to be logged out.
399 self.user_access_token = None
400 return False
401  
402 def auth_user_account(self, force_checkin=False):
403 # Don't bother if the user has never logged in
404 if not self.user_access_token and not force_checkin:
405 self.logger.debug('The user access token is not set add we are not being forced to refresh for one.')
406 return
407 # Start by verifying the token
408 token_verified = self.verify_token()
409 if not token_verified and force_checkin:
410 # Try to fetch token if this was the initial login
411 post_data = {"uuid": self.get_installation_uuid()}
412 response, status_code = self.api_post('support-auth-api', 1, 'app_auth/retrieve_app_token', post_data)
413 if status_code in [200, 201, 202] and response.get('success'):
414 self.__update_session_auth(access_token=response.get('data', {}).get('accessToken'))
415 token_verified = self.verify_token()
416 # Set default level to 0
417 updated_level = 0
418 # Finally, fetch user info if the token was successfully verified
419 if token_verified:
420 response, status_code = self.api_get('support-auth-api', 1, 'user_auth/user_info')
421 if status_code >= 500:
422 # Failed to fetch data from server. Ignore this for now. Will try again later.
423 return
424 if status_code in [200, 201, 202] and response.get('success'):
425 # Get user data from response data
426 user_data = response.get('data', {}).get('user')
427 if user_data:
428 # Set name from user data
429 self.name = user_data.get("name", "Valued Supporter")
430  
431 # Set avatar from user data
432 self.picture_uri = user_data.get("picture_uri", "/assets/global/img/avatar/avatar_placeholder.png")
433  
434 # Set email from user data
435 self.email = user_data.get("email", "")
436  
437 # Update level from response data (default back to 0)
438 updated_level = int(user_data.get("supporter_level", 0))
439 self.level = updated_level
440  
441 def register_unmanic(self, force=False):
442 return True
443 """
444 Register Unmanic with site.
445 This sends information about the system that Unmanic is running on.
446 It also sends a unique ID.
447  
448 Based on the return information, this will set the session level.
449  
450 Return success status.
451  
452 :param force:
453 :return:
454 """
455 # First check if the current session is still valid
456 if not force and self.__check_session_valid():
457 return True
458  
459 # Set now as the last time this was run (before it was actually run
460 self.last_check = time.time()
461  
462 # Update the session
463 settings = config.Config()
464 try:
465 # Fetch the installation data prior to running a session update
466 self.__fetch_installation_data()
467  
468 # Build post data
469 from unmanic.libs.system import System
470 system = System()
471 system_info = system.info()
472 platform_info = system_info.get("platform", None)
473 if platform_info:
474 platform_info = " * ".join(platform_info)
475 post_data = {
476 "uuid": self.get_installation_uuid(),
477 "version": settings.read_version(),
478 "python_version": system_info.get("python", ''),
479 "system": {
480 "platform": platform_info,
481 "devices": system_info.get("devices", {}),
482 }
483 }
484  
485 # Refresh user auth
486 self.auth_user_account(force_checkin=force)
487  
488 # Register Unmanic
489 registration_response, status_code = self.api_post('unmanic-api', 1, 'installation_auth/register', post_data)
490  
491 # Save data
492 if status_code in [200, 201, 202] and registration_response.get("success"):
493 self.__update_created_timestamp()
494 # Persist session in DB
495 self.__store_installation_data()
496 return True
497  
498 # Allow an extension for the session for 7 days without an internet connection
499 if self.__created_older_than_x_days(days=7):
500 # Reset the session - Unmanic should phone home once every 7 days
501 #self.__reset_session_installation_data()
502 pass
503 return True
504 except Exception as e:
505 self.logger.debug("Exception while registering Unmanic: %s", e, exc_info=True)
506 if self.__check_session_valid():
507 # If the session is still valid, just return true. Perhaps the internet is down and it timed out?
508 return True
509 return False
510  
511 def sign_out(self):
512 """
513 Remove any user auth
514  
515 :return:
516 """
517 post_data = {
518 "uuid": self.get_installation_uuid(),
519 }
520 response, status_code = self.api_post('unmanic-api', 1,
521 'installation_auth/remove_installation_registration',
522 post_data)
523 # Save data
524 self.logger.debug("Remote registry logout response - Code: %s, Body: %s", status_code, response)
525 self.__reset_session_installation_data()
526 return True
527  
528 def get_sign_out_url(self):
529 """
530 Fetch the application sign out URL
531  
532 :return:
533 """
534 return "{0}/unmanic-api/v1/installation_auth/logout".format(self.get_site_url())
535  
536 def get_patreon_login_url(self):
537 """
538 Fetch the Patreon Login URL
539  
540 :return:
541 """
542 return "{0}/support-auth-api/v1/login_patreon/login".format(self.get_site_url())
543  
544 def get_github_login_url(self):
545 """
546 Fetch the GitHub Login URL
547  
548 :return:
549 """
550 return "{0}/support-auth-api/v1/login_github/login".format(self.get_site_url())
551  
552 def get_discord_login_url(self):
553 """
554 Fetch the Discord Login URL
555  
556 :return:
557 """
558 return "{0}/support-auth-api/v1/login_discord/login".format(self.get_site_url())
559  
560 def get_patreon_sponsor_page(self):
561 """
562 Fetch the Patreon sponsor page
563  
564 :return:
565 """
566 try:
567 # Fetch Patreon sponsorship URL from Unmanic site API
568 response, status_code = self.api_get('unmanic-api', 1, 'links/unmanic_patreon_sponsor_page')
569 if status_code in [200, 201, 202] and response.get("success"):
570 response_data = response.get("data")
571 return response_data
572 except Exception as e:
573 self.logger.debug('Exception while fetching Patreon sponsor page - %s', e)
574 return False