kapsikkum-unmanic – Blame information for rev 1
?pathlinks?
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 |