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 = 0
58  
59 """
60 non supporter library count
61 """
62 library_count = 2
63  
64 """
65 non supporter linked installations count
66 """
67 link_count = 5
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 """
443 Register Unmanic with site.
444 This sends information about the system that Unmanic is running on.
445 It also sends a unique ID.
446  
447 Based on the return information, this will set the session level.
448  
449 Return success status.
450  
451 :param force:
452 :return:
453 """
454 # First check if the current session is still valid
455 if not force and self.__check_session_valid():
456 return True
457  
458 # Set now as the last time this was run (before it was actually run
459 self.last_check = time.time()
460  
461 # Update the session
462 settings = config.Config()
463 try:
464 # Fetch the installation data prior to running a session update
465 self.__fetch_installation_data()
466  
467 # Build post data
468 from unmanic.libs.system import System
469 system = System()
470 system_info = system.info()
471 platform_info = system_info.get("platform", None)
472 if platform_info:
473 platform_info = " * ".join(platform_info)
474 post_data = {
475 "uuid": self.get_installation_uuid(),
476 "version": settings.read_version(),
477 "python_version": system_info.get("python", ''),
478 "system": {
479 "platform": platform_info,
480 "devices": system_info.get("devices", {}),
481 }
482 }
483  
484 # Refresh user auth
485 self.auth_user_account(force_checkin=force)
486  
487 # Register Unmanic
488 registration_response, status_code = self.api_post('unmanic-api', 1, 'installation_auth/register', post_data)
489  
490 # Save data
491 if status_code in [200, 201, 202] and registration_response.get("success"):
492 self.__update_created_timestamp()
493 # Persist session in DB
494 self.__store_installation_data()
495 return True
496  
497 # Allow an extension for the session for 7 days without an internet connection
498 if self.__created_older_than_x_days(days=7):
499 # Reset the session - Unmanic should phone home once every 7 days
500 self.__reset_session_installation_data()
501 return False
502 except Exception as e:
503 self.logger.debug("Exception while registering Unmanic: %s", e, exc_info=True)
504 if self.__check_session_valid():
505 # If the session is still valid, just return true. Perhaps the internet is down and it timed out?
506 return True
507 return False
508  
509 def sign_out(self):
510 """
511 Remove any user auth
512  
513 :return:
514 """
515 post_data = {
516 "uuid": self.get_installation_uuid(),
517 }
518 response, status_code = self.api_post('unmanic-api', 1,
519 'installation_auth/remove_installation_registration',
520 post_data)
521 # Save data
522 self.logger.debug("Remote registry logout response - Code: %s, Body: %s", status_code, response)
523 self.__reset_session_installation_data()
524 return True
525  
526 def get_sign_out_url(self):
527 """
528 Fetch the application sign out URL
529  
530 :return:
531 """
532 return "{0}/unmanic-api/v1/installation_auth/logout".format(self.get_site_url())
533  
534 def get_patreon_login_url(self):
535 """
536 Fetch the Patreon Login URL
537  
538 :return:
539 """
540 return "{0}/support-auth-api/v1/login_patreon/login".format(self.get_site_url())
541  
542 def get_github_login_url(self):
543 """
544 Fetch the GitHub Login URL
545  
546 :return:
547 """
548 return "{0}/support-auth-api/v1/login_github/login".format(self.get_site_url())
549  
550 def get_discord_login_url(self):
551 """
552 Fetch the Discord Login URL
553  
554 :return:
555 """
556 return "{0}/support-auth-api/v1/login_discord/login".format(self.get_site_url())
557  
558 def get_patreon_sponsor_page(self):
559 """
560 Fetch the Patreon sponsor page
561  
562 :return:
563 """
564 try:
565 # Fetch Patreon sponsorship URL from Unmanic site API
566 response, status_code = self.api_get('unmanic-api', 1, 'links/unmanic_patreon_sponsor_page')
567 if status_code in [200, 201, 202] and response.get("success"):
568 response_data = response.get("data")
569 return response_data
570 except Exception as e:
571 self.logger.debug('Exception while fetching Patreon sponsor page - %s', e)
572 return False