From 7e33b17f4bd93f893870d7900a15c0126939f3f7 Mon Sep 17 00:00:00 2001 From: Noiredd Date: Sat, 23 May 2020 19:46:54 +0200 Subject: [PATCH] session persistence and "remember me" option also updates the login request to return a long-term session --- filmatyk/filmweb.py | 41 +++++++++++++++++++++++++++++++++++++++-- filmatyk/gui.py | 27 ++++++++++++++++++++++----- filmatyk/options.py | 1 + filmatyk/userdata.py | 28 +++++++++++++++++----------- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/filmatyk/filmweb.py b/filmatyk/filmweb.py index 4ba6ddc..68b08af 100644 --- a/filmatyk/filmweb.py +++ b/filmatyk/filmweb.py @@ -1,8 +1,10 @@ from datetime import date +import binascii +import html import json +import pickle from bs4 import BeautifulSoup as BS -import html import requests_html import containers @@ -10,6 +12,7 @@ ConnectionError = requests_html.requests.ConnectionError + class Constants(): """URLs and HTML component names for data acquisition. @@ -51,6 +54,8 @@ def login(username, password): auth_package = { 'j_username': username, 'j_password': password, + '_login_redirect_url': '', + '_prm': True, } # Catch connection errors try: @@ -77,11 +82,19 @@ def enforceSession(fun): https://stackoverflow.com/q/21382801/6919631 https://stackoverflow.com/q/11058686/6919631 The bottom line is that it should NEVER be called directly. + + Also checks if the session cookies were changed in the process of making + a request. """ def wrapper(*args, **kwargs): self = args[0] if self.checkSession(): - return fun(*args, **kwargs) + old_cookies = set(self.session.cookies.values()) + result = fun(*args, **kwargs) + new_cookies = set(self.session.cookies.values()) + if old_cookies != new_cookies: + self.isDirty = True + return result else: return None return wrapper @@ -91,6 +104,7 @@ def __init__(self, login_handler, username:str=''): self.constants = Constants(username) self.login_handler = login_handler self.session = None + self.isDirty = False self.parsingRules = {} for container in containers.classByString.keys(): self.__cacheParsingRules(container) @@ -147,11 +161,16 @@ def checkSession(self): (cause we'll nearly always have a session, except it might sometimes get stale resulting in an acquisition failure) """ + session_requested = False if not self.session: self.requestSession() + session_requested = True # Check again - in case the user cancelled a login if not self.session: return False + # If a new session was requested in the process - set the dirty flag + if session_requested: + self.isDirty = True # At this point everything is definitely safe return True @@ -172,6 +191,24 @@ def requestSession(self): return None self.session = session + def storeSession(self): + """Stores the sessions cookies to a base64-encoded pickle string.""" + if self.session: + cookies_bin = pickle.dumps(self.session.cookies) + cookies_str = binascii.b2a_base64(cookies_bin).decode('utf-8').strip() + return cookies_str + else: + return 'null' + + def restoreSession(self, pickle_str:str): + """Restores the session cookies from a base64-encoded pickle string.""" + if pickle_str == 'null': + return + self.session = requests_html.HTMLSession() + cookies_bin = binascii.a2b_base64(pickle_str.encode('utf-8')) + cookies_obj = pickle.loads(cookies_bin) + self.session.cookies = cookies_obj + @enforceSession def getNumOf(self, itemtype:str): """Return the number of items of a given type that the user has rated. diff --git a/filmatyk/gui.py b/filmatyk/gui.py index 959ef2a..76948b2 100644 --- a/filmatyk/gui.py +++ b/filmatyk/gui.py @@ -77,10 +77,12 @@ def __construct(self): self.passwordEntry = tk.Entry(master=cw, width=20, show='*') self.passwordEntry.grid(row=2, column=1, sticky=tk.W) self.passwordEntry.bind('', self._setStateGood) + self.rememberBox = tk.Checkbutton(self.window, text='pamiętaj mnie', variable=Options.var('rememberLogin')) + self.rememberBox.grid(row=3, column=1, columnspan=2, sticky=tk.W) self.infoLabel = tk.Label(master=cw, text='') - self.infoLabel.grid(row=3, column=0, columnspan=2) - tk.Button(master=cw, text='Zaloguj', command=self._loginClick).grid(row=4, column=1, sticky=tk.W) - tk.Button(master=cw, text='Anuluj', command=self._cancelClick).grid(row=4, column=0, sticky=tk.E) + self.infoLabel.grid(row=4, column=0, columnspan=2) + tk.Button(master=cw, text='Zaloguj', command=self._loginClick).grid(row=5, column=1, sticky=tk.W) + tk.Button(master=cw, text='Anuluj', command=self._cancelClick).grid(row=5, column=0, sticky=tk.E) self.window.withdraw() def centerWindow(self): @@ -171,6 +173,7 @@ def __init__(self, debugMode=False, isOnLinux=False): self.presenters = [] # instantiate Presenters and Databases self.api = FilmwebAPI(self.loginHandler.requestLogin, userdata.username) + self.api.restoreSession(userdata.session_pkl) movieDatabase = Database.restoreFromString('Movie', userdata.movies_data, self.api, self._setProgress) self.databases.append(movieDatabase) moviePresenter = Presenter(self, self.api, movieDatabase, userdata.movies_conf) @@ -240,7 +243,8 @@ def centerWindow(self): y = hs/2 - h/2 self.root.geometry('+{:.0f}+{:.0f}'.format(x, y)) - #USER DATA MANAGEMENT + # USER DATA MANAGEMENT + def getFilename(self): if self.debugMode: return self.filename @@ -249,14 +253,24 @@ def getFilename(self): return os.path.join(userdir, subpath) def saveUserData(self): + """Save the user data, if any of it has changed during the run time.""" # if for any reason the first update hasn't commenced - don't save anything if self.api.username is None: return + # if the session is set to be stored - serialize it + # if it is also dirty - set the flag for a check later + session_pkl = 'null' + session_isDirty = False + if Options.get('rememberLogin'): + session_pkl = self.api.storeSession() + if self.api.isDirty: + session_isDirty = True # if there is no need to save anything - stop right there too if not ( any([db.isDirty for db in self.databases]) or any([ps.isDirty for ps in self.presenters]) or - Options.isDirty + Options.isDirty or + session_isDirty ): return # construct the UserData object @@ -269,6 +283,7 @@ def saveUserData(self): games_conf=self.presenters[2].storeToString(), games_data=self.databases[2].storeToString(), options_json=Options.storeToString(), + session_pkl=session_pkl, ) # request the manager to save it self.dataManager.save(serialized_data) @@ -277,6 +292,8 @@ def saveUserData(self): db.isDirty = False for ps in self.presenters: ps.isDirty = False + Options.isDirty = False + self.api.isDirty = False #CALLBACKS def _setProgress(self, value:int, abort:bool=False): diff --git a/filmatyk/options.py b/filmatyk/options.py index 7ca72c7..9a1a7aa 100644 --- a/filmatyk/options.py +++ b/filmatyk/options.py @@ -33,6 +33,7 @@ class _Options(): Defining a new option is done simply by adding it to the prototypes list. """ option_prototypes = [ + ('rememberLogin', tk.BooleanVar, True), ] def __init__(self): diff --git a/filmatyk/userdata.py b/filmatyk/userdata.py index 9aa9ea1..3632907 100644 --- a/filmatyk/userdata.py +++ b/filmatyk/userdata.py @@ -46,23 +46,25 @@ class UserData(object): def __init__( self, username='', + options_json='{}', + session_pkl='null', movies_conf='', movies_data='', series_conf='', series_data='', games_conf='', games_data='', - options_json='{}', is_empty=True ): self.username = username + self.options_json = options_json + self.session_pkl = session_pkl self.movies_conf = movies_conf self.movies_data = movies_data self.series_conf = series_conf self.series_data = series_data self.games_conf = games_conf self.games_data = games_data - self.options_json = options_json self.is_empty = is_empty @@ -104,6 +106,8 @@ def save(self, userData): user_file.write(userData.username + '\n') user_file.write('#OPTIONS\n') user_file.write(userData.options_json + '\n') + user_file.write('#SESSION\n') + user_file.write(userData.session_pkl + '\n') user_file.write('#MOVIES\n') user_file.write(userData.movies_conf + '\n') user_file.write(userData.movies_data + '\n') @@ -153,12 +157,13 @@ def readFile(self): This always has to be done first (independent of the actual content), as the version string must be extracted before doing anything further. Lines starting with '#' are always ignored as comments. + Empty lines are always ignored. """ with open(self.path, 'r') as user_file: user_data = [ line.strip('\n') for line in user_file.readlines() - if not line.startswith('#') + if not line.startswith('#') and len(line) > 1 ] return user_data @@ -205,8 +210,8 @@ def decorator(loader): class Loaders(object): """Just a holder for different data loading functions. - It's friends with DataManager class, that is: updates its "loaders" ODict - with any loader defined here. + It's friends with DataManager class, that is: updates its list of loaders + with all loaders defined here. """ @DataManager.registerLoaderSince('1.0.0-beta.1') def loader100b(user_data): @@ -225,10 +230,11 @@ def loader100b4(user_data): return UserData( username=user_data[1], options_json=user_data[2], - movies_conf=user_data[3], - movies_data=user_data[4], - series_conf=user_data[5], - series_data=user_data[6], - games_conf=user_data[7], - games_data=user_data[8], + session_pkl=user_data[3], + movies_conf=user_data[4], + movies_data=user_data[5], + series_conf=user_data[6], + series_data=user_data[7], + games_conf=user_data[8], + games_data=user_data[9], )