From 62f01ca0d1711bcdf4dbc64e87e90e19e2217844 Mon Sep 17 00:00:00 2001 From: obosob Date: Mon, 10 Aug 2015 09:19:52 +0100 Subject: [PATCH 01/19] Don't forget to add selfposts and x-posts to history Links for selfposts and x-posts don't go purple after my last PR (#126). This'll fix it. --- rtv/subreddit.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 909c3f0..32db434 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -106,23 +106,19 @@ class SubredditPage(BasePage): page = SubmissionPage(self.stdscr, self.reddit, url=data['permalink']) page.loop() - if data['url'] == 'selfpost': - global history - history.add(data['url_full']) - @SubredditController.register(curses.KEY_ENTER, 10, 'o') def open_link(self): "Open a link with the webbrowser" data = self.content.get(self.nav.absolute_index) url = data['url_full'] + global history + history.add(url) if data['url_type'] in ['x-post', 'selfpost']: page = SubmissionPage(self.stdscr, self.reddit, url=url) page.loop() else: open_browser(url) - global history - history.add(url) @SubredditController.register('c') def post_submission(self): From 0879dc3c57f3fe7afcecfee70767f791c6c36436 Mon Sep 17 00:00:00 2001 From: obosob Date: Mon, 10 Aug 2015 13:05:11 +0100 Subject: [PATCH 02/19] Update subreddit.py --- rtv/subreddit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 32db434..68038ae 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -105,6 +105,9 @@ class SubredditPage(BasePage): data = self.content.get(self.nav.absolute_index) page = SubmissionPage(self.stdscr, self.reddit, url=data['permalink']) page.loop() + if data['url_type'] == 'selfpost': + global history + history.add(data['url_full']) @SubredditController.register(curses.KEY_ENTER, 10, 'o') def open_link(self): From 248a87a7e599c4204f046494e358ba41df70c1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 10 Aug 2015 18:06:55 +0200 Subject: [PATCH 03/19] Some additional keybindings --- README.rst | 2 +- rtv/docs.py | 2 +- rtv/subscriptions.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 2d87cb6..59c68bc 100644 --- a/README.rst +++ b/README.rst @@ -84,7 +84,7 @@ Once you are logged in your username will appear in the top-right corner of the :``c``: Compose a new post or comment :``e``: Edit an existing post or comment :``d``: Delete an existing post or comment -:``s``: Open subscribed subreddits list +:``s``: Open/close subscribed subreddits list -------------- Subreddit Mode diff --git a/rtv/docs.py b/rtv/docs.py index d2c5482..8775728 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -42,7 +42,7 @@ Authenticated Commands `c` : Compose a new post or comment `e` : Edit an existing post or comment `d` : Delete an existing post or comment - `s` : Open subscribed subreddits list + `s` : Open/close subscribed subreddits list Subreddit Mode `l` or `RIGHT` : Enter the selected submission diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index a4c69a0..8097871 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -37,7 +37,7 @@ class SubscriptionPage(BasePage): self.content = SubscriptionContent.get_list(self.reddit, self.loader) self.nav = Navigator(self.content.get) - @SubscriptionController.register(curses.KEY_ENTER, 10) + @SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT) def open_selected_subreddit(self): "Open the selected subreddit" @@ -46,7 +46,7 @@ class SubscriptionPage(BasePage): page = SubredditPage(self.stdscr, self.reddit, data['name'][2:]) # Strip the leading /r page.loop() - @SubscriptionController.register(curses.KEY_LEFT) + @SubscriptionController.register(curses.KEY_LEFT, 's') def close_subscriptions(self): "Close subscriptions and return to the subreddit page" From a02322d5fe9cc3daa0baa3f263bf480ca3aadaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 10 Aug 2015 18:17:51 +0200 Subject: [PATCH 04/19] Remove old method (moved in SubredditController) --- rtv/page.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/rtv/page.py b/rtv/page.py index 686f5d4..cc4d7b4 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -412,22 +412,6 @@ class BasePage(object): s.catch = False self.refresh_content() - @BaseController.register('s') - def get_subscriptions(self): - """ - Displays subscribed subreddits - """ - - if not self.reddit.is_logged_in(): - show_notification(self.stdscr, ['Not logged in']) - return - - data = self.content.get(self.nav.absolute_index) - with self.safe_call as s: - subscriptions = SubscriptionPage(self.stdscr, self.reddit) - subscriptions.loop() - self.refresh_content() - @BaseController.register('i') def get_inbox(self): """ From 691986abd445063175822e99f1c4897cdd7a5a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Wed, 12 Aug 2015 14:39:49 +0200 Subject: [PATCH 05/19] Refactoring the way a selected sub is opened --- rtv/content.py | 2 +- rtv/subreddit.py | 6 ++++++ rtv/subscriptions.py | 14 ++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/rtv/content.py b/rtv/content.py index f77c7a9..2586d89 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -153,7 +153,7 @@ class BaseContent(object): data = {} data['object'] = subscription data['type'] = 'Subscription' - data['name'] = "/r/" + subscription._case_name + data['name'] = "/r/" + subscription.display_name data['title'] = subscription.title return data diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 5b2ca41..eaecef9 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -167,9 +167,15 @@ class SubredditPage(BasePage): show_notification(self.stdscr, ['Not logged in']) return + # Open subscriptions page page = SubscriptionPage(self.stdscr, self.reddit) page.loop() + # When user has chosen a subreddit in the subscriptions list, refresh content with the selected subreddit + chosen_subreddit = page.get_selected_subreddit_data() + if chosen_subreddit is not None: + self.refresh_content(name=chosen_subreddit['name']) + @staticmethod def draw_item(win, data, inverted=False): diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index 8097871..1bddf1a 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -17,6 +17,7 @@ class SubscriptionPage(BasePage): def __init__(self, stdscr, reddit): self.controller = SubscriptionController(self) self.loader = LoadScreen(stdscr) + self.selected_subreddit_data = None content = SubscriptionContent.get_list(reddit, self.loader) super(SubscriptionPage, self).__init__(stdscr, reddit, content) @@ -30,6 +31,9 @@ class SubscriptionPage(BasePage): cmd = self.stdscr.getch() self.controller.trigger(cmd) + def get_selected_subreddit_data(self): + return self.selected_subreddit_data + @SubscriptionController.register(curses.KEY_F5, 'r') def refresh_content(self): "Re-download all subscriptions and reset the page index" @@ -38,13 +42,11 @@ class SubscriptionPage(BasePage): self.nav = Navigator(self.content.get) @SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT) - def open_selected_subreddit(self): - "Open the selected subreddit" + def store_selected_subreddit(self): + "Store the selected subreddit and return to the subreddit page" - from .subreddit import SubredditPage - data = self.content.get(self.nav.absolute_index) - page = SubredditPage(self.stdscr, self.reddit, data['name'][2:]) # Strip the leading /r - page.loop() + self.selected_subreddit_data = self.content.get(self.nav.absolute_index) + self.active = False @SubscriptionController.register(curses.KEY_LEFT, 's') def close_subscriptions(self): From 2c7a0ed4ba7cf281067658316d537365d86e7ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Wed, 12 Aug 2015 15:27:55 +0200 Subject: [PATCH 06/19] Wrap subreddit title instead of name --- rtv/content.py | 2 +- rtv/subscriptions.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rtv/content.py b/rtv/content.py index 2586d89..43dd331 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -404,7 +404,7 @@ class SubscriptionContent(BaseContent): self._subscription_data.append(data) data = self._subscription_data[index] - data['split_title'] = wrap_text(data['name'], width=n_cols) + data['split_title'] = wrap_text(data['title'], width=n_cols) data['n_rows'] = len(data['split_title']) + 1 data['offset'] = 0 diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index 1bddf1a..cbb5926 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -66,9 +66,9 @@ class SubscriptionPage(BasePage): n_title = len(data['split_title']) for row, text in enumerate(data['split_title'], start=offset): if row in valid_rows: - attr = curses.A_BOLD | Color.YELLOW - add_line(win, u'{name}'.format(**data), row, 1, attr) + add_line(win, text, row, 1) row = n_title + offset if row in valid_rows: - add_line(win, u'{title}'.format(**data), row, 1) + attr = curses.A_BOLD | Color.YELLOW + add_line(win, u'{name}'.format(**data), row, 1, attr) From ac939b5f96b4ae2ed545f4e5698244346898e595 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Wed, 12 Aug 2015 10:12:53 -0700 Subject: [PATCH 07/19] Switched back to putting the subreddit name first on the submission page. --- rtv/content.py | 5 ++++- rtv/subreddit.py | 8 ++++---- rtv/subscriptions.py | 21 ++++++++++----------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/rtv/content.py b/rtv/content.py index fca4830..afa64bd 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -385,14 +385,17 @@ class SubredditContent(BaseContent): return data class SubscriptionContent(BaseContent): + def __init__(self, subscriptions, loader): + self.name = "Subscriptions" + self.order = None self._loader = loader self._subscriptions = subscriptions self._subscription_data = [] @classmethod - def get_list(cls, reddit, loader): + def from_user(cls, reddit, loader): try: with loader(): subscriptions = reddit.get_my_subreddits(limit=None) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index fdebfce..a869a3b 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -177,10 +177,10 @@ class SubredditPage(BasePage): page = SubscriptionPage(self.stdscr, self.reddit) page.loop() - # When user has chosen a subreddit in the subscriptions list, refresh content with the selected subreddit - chosen_subreddit = page.get_selected_subreddit_data() - if chosen_subreddit is not None: - self.refresh_content(name=chosen_subreddit['name']) + # When user has chosen a subreddit in the subscriptions list, + # refresh content with the selected subreddit + if page.selected_subreddit_data is not None: + self.refresh_content(name=page.selected_subreddit_data['name']) @staticmethod def draw_item(win, data, inverted=False): diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index cbb5926..8ef2094 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -14,12 +14,14 @@ class SubscriptionController(BaseController): character_map = {} class SubscriptionPage(BasePage): + def __init__(self, stdscr, reddit): + self.controller = SubscriptionController(self) self.loader = LoadScreen(stdscr) self.selected_subreddit_data = None - content = SubscriptionContent.get_list(reddit, self.loader) + content = SubscriptionContent.from_user(reddit, self.loader) super(SubscriptionPage, self).__init__(stdscr, reddit, content) def loop(self): @@ -31,9 +33,6 @@ class SubscriptionPage(BasePage): cmd = self.stdscr.getch() self.controller.trigger(cmd) - def get_selected_subreddit_data(self): - return self.selected_subreddit_data - @SubscriptionController.register(curses.KEY_F5, 'r') def refresh_content(self): "Re-download all subscriptions and reset the page index" @@ -48,7 +47,7 @@ class SubscriptionPage(BasePage): self.selected_subreddit_data = self.content.get(self.nav.absolute_index) self.active = False - @SubscriptionController.register(curses.KEY_LEFT, 's') + @SubscriptionController.register(curses.KEY_LEFT, 'h', 's') def close_subscriptions(self): "Close subscriptions and return to the subreddit page" @@ -63,12 +62,12 @@ class SubscriptionPage(BasePage): valid_rows = range(0, n_rows) offset = 0 if not inverted else -(data['n_rows'] - n_rows) - n_title = len(data['split_title']) - for row, text in enumerate(data['split_title'], start=offset): - if row in valid_rows: - add_line(win, text, row, 1) - - row = n_title + offset + row = offset if row in valid_rows: attr = curses.A_BOLD | Color.YELLOW add_line(win, u'{name}'.format(**data), row, 1, attr) + + row = offset + 1 + for row, text in enumerate(data['split_title'], start=row): + if row in valid_rows: + add_line(win, text, row, 1) \ No newline at end of file From f19b96948fd09018239f500e094234e55e37612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sun, 16 Aug 2015 05:45:14 +0200 Subject: [PATCH 08/19] OAuth authentication --- rtv/__main__.py | 24 +++++++++++-- rtv/oauth.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ rtv/subreddit.py | 2 +- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 rtv/oauth.py diff --git a/rtv/__main__.py b/rtv/__main__.py index 8d6e561..b084de4 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -7,7 +7,7 @@ import logging import requests import praw import praw.errors -from six.moves import configparser +import configparser from . import config from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError @@ -15,6 +15,7 @@ from .curses_helpers import curses_session from .submission import SubmissionPage from .subreddit import SubredditPage from .docs import * +from .oauth import load_oauth_config, read_setting, write_setting, authorize from .__version__ import __version__ __all__ = [] @@ -106,9 +107,25 @@ def main(): print('Connecting...') reddit = praw.Reddit(user_agent=AGENT) reddit.config.decode_html_entities = False - if args.username: + if read_setting(key="authorization_token") is None: + print('Hello OAuth login helper!') + authorize(reddit) + else: + oauth_config = load_oauth_config() + oauth_data = {} + if oauth_config.has_section('oauth'): + oauth_data = dict(oauth_config.items('oauth')) + + reddit.set_oauth_app_info(oauth_data['client_id'], + oauth_data['client_secret'], + oauth_data['redirect_uri']) + + reddit.set_access_credentials(scope=set(oauth_data['scope'].split('-')), + access_token=oauth_data['authorization_token'], + refresh_token=oauth_data['refresh_token']) + """if args.username: # PRAW will prompt for password if it is None - reddit.login(args.username, args.password) + reddit.login(args.username, args.password)""" with curses_session() as stdscr: if args.link: page = SubmissionPage(stdscr, reddit, url=args.link) @@ -133,6 +150,7 @@ def main(): pass finally: # Ensure sockets are closed to prevent a ResourceWarning + print(reddit.is_oauth_session()) reddit.handler.http.close() sys.exit(main()) diff --git a/rtv/oauth.py b/rtv/oauth.py new file mode 100644 index 0000000..868b69f --- /dev/null +++ b/rtv/oauth.py @@ -0,0 +1,87 @@ +import configparser +import os +import webbrowser +import uuid + +__all__ = [] + +def get_config_file_path(): + HOME = os.path.expanduser('~') + XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) + config_paths = [ + os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), + os.path.join(HOME, '.rtv') + ] + + # get the first existing config file + for config_path in config_paths: + if os.path.exists(config_path): + break + + return config_path + +def load_oauth_config(): + config = configparser.ConfigParser() + config_path = get_config_file_path() + config.read(config_path) + + return config + +def read_setting(key, section='oauth'): + config = load_oauth_config() + + try: + setting = config[section][key] + except KeyError: + return None + + return setting + +def write_setting(key, value, section='oauth'): + config = load_oauth_config() + + config[section][key] = value + with open(config_path, 'w') as cfg_file: + config.write(cfg_file) + +def authorize(reddit): + config = load_oauth_config() + + settings = {} + if config.has_section('oauth'): + settings = dict(config.items('oauth')) + + scopes = ["edit", "history", "identity", "mysubreddits", "privatemessages", "read", "report", "save", "submit", "subscribe", "vote"] + + reddit.set_oauth_app_info(settings['client_id'], + settings['client_secret'], + settings['redirect_uri']) + + # Generate a random UUID + hex_uuid = uuid.uuid4().hex + + permission_ask_page_link = reddit.get_authorize_url(str(hex_uuid), scope=scopes, refreshable=True) + input("You will now be redirected to your web browser. Press Enter to continue.") + webbrowser.open(permission_ask_page_link) + + print("After allowing rtv app access, you will land on a page giving you a state and a code string. Please enter them here.") + final_state = input("State : ") + final_code = input("Code : ") + + # Check if UUID matches obtained state + # (if not, authorization process is compromised, and I'm giving up) + if hex_uuid == final_state: + print("Obtained state matches UUID") + else: + print("Obtained state does not match UUID, stopping.") + return + + # Get access information (authorization token) + info = reddit.get_access_information(final_code) + config['oauth']['authorization_token'] = info['access_token'] + config['oauth']['refresh_token'] = info['refresh_token'] + config['oauth']['scope'] = '-'.join(info['scope']) + + config_path = get_config_file_path() + with open(config_path, 'w') as cfg_file: + config.write(cfg_file) diff --git a/rtv/subreddit.py b/rtv/subreddit.py index a869a3b..7fc44e5 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -169,7 +169,7 @@ class SubredditPage(BasePage): def open_subscriptions(self): "Open user subscriptions page" - if not self.reddit.is_logged_in(): + if not self.reddit.is_logged_in() and not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return From ef38b112a2cf46fe1bbb52a9633fd42fad03ddb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sun, 16 Aug 2015 22:21:24 +0200 Subject: [PATCH 09/19] Update method name --- rtv/subscriptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index 8ef2094..ae8981e 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -37,7 +37,7 @@ class SubscriptionPage(BasePage): def refresh_content(self): "Re-download all subscriptions and reset the page index" - self.content = SubscriptionContent.get_list(self.reddit, self.loader) + self.content = SubscriptionContent.from_user(self.reddit, self.loader) self.nav = Navigator(self.content.get) @SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT) @@ -70,4 +70,4 @@ class SubscriptionPage(BasePage): row = offset + 1 for row, text in enumerate(data['split_title'], start=row): if row in valid_rows: - add_line(win, text, row, 1) \ No newline at end of file + add_line(win, text, row, 1) From efed781fa160806888077aba435bd489d50b5141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 17 Aug 2015 00:36:18 +0200 Subject: [PATCH 10/19] Refactoring and making rtv OAuth-compliant --- rtv/__main__.py | 71 ++++++++++------- rtv/config.py | 11 ++- rtv/docs.py | 7 +- rtv/oauth.py | 184 ++++++++++++++++++++++++++++--------------- rtv/page.py | 42 +++++----- rtv/submission.py | 13 ++- rtv/subreddit.py | 26 ++++-- rtv/subscriptions.py | 8 +- 8 files changed, 239 insertions(+), 123 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index b084de4..043244e 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -11,16 +11,16 @@ import configparser from . import config from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError -from .curses_helpers import curses_session +from .curses_helpers import curses_session, LoadScreen from .submission import SubmissionPage from .subreddit import SubredditPage from .docs import * -from .oauth import load_oauth_config, read_setting, write_setting, authorize +from .oauth import OAuthTool from .__version__ import __version__ __all__ = [] -def load_config(): +def open_config(): """ Search for a configuration file at the location ~/.rtv and attempt to load saved settings for things like the username and password. @@ -41,6 +41,15 @@ def load_config(): config.read(config_path) break + return config + +def load_rtv_config(): + """ + Attempt to load saved settings for things like the username and password. + """ + + config = open_config() + defaults = {} if config.has_section('rtv'): defaults = dict(config.items('rtv')) @@ -50,6 +59,18 @@ def load_config(): return defaults +def load_oauth_config(): + """ + Attempt to load saved OAuth settings + """ + + config = open_config() + + defaults = {} + if config.has_section('oauth'): + defaults = dict(config.items('oauth')) + + return defaults def command_line(): @@ -69,6 +90,13 @@ def command_line(): group.add_argument('-u', dest='username', help='reddit username') group.add_argument('-p', dest='password', help='reddit password') + oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH) + oauth_group.add_argument('--client-id', dest='client_id', help='OAuth app ID') + oauth_group.add_argument('--redurect-uri', dest='redirect_uri', help='OAuth app redirect URI') + oauth_group.add_argument('--auth-token', dest='authorization_token', help='OAuth authorization token') + oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') + oauth_group.add_argument('--scope', dest='scope', help='OAuth app scope') + args = parser.parse_args() return args @@ -81,7 +109,8 @@ def main(): locale.setlocale(locale.LC_ALL, '') args = command_line() - local_config = load_config() + local_rtv_config = load_rtv_config() + local_oauth_config = load_oauth_config() # set the terminal title title = 'rtv {0}'.format(__version__) @@ -92,10 +121,14 @@ def main(): # Fill in empty arguments with config file values. Paramaters explicitly # typed on the command line will take priority over config file params. - for key, val in local_config.items(): + for key, val in local_rtv_config.items(): if getattr(args, key, None) is None: setattr(args, key, val) + for k, v in local_oauth_config.items(): + if getattr(args, k, None) is None: + setattr(args, k, v) + config.unicode = (not args.ascii) # Squelch SSL warnings for Ubuntu @@ -107,34 +140,19 @@ def main(): print('Connecting...') reddit = praw.Reddit(user_agent=AGENT) reddit.config.decode_html_entities = False - if read_setting(key="authorization_token") is None: - print('Hello OAuth login helper!') - authorize(reddit) - else: - oauth_config = load_oauth_config() - oauth_data = {} - if oauth_config.has_section('oauth'): - oauth_data = dict(oauth_config.items('oauth')) - - reddit.set_oauth_app_info(oauth_data['client_id'], - oauth_data['client_secret'], - oauth_data['redirect_uri']) - - reddit.set_access_credentials(scope=set(oauth_data['scope'].split('-')), - access_token=oauth_data['authorization_token'], - refresh_token=oauth_data['refresh_token']) - """if args.username: - # PRAW will prompt for password if it is None - reddit.login(args.username, args.password)""" with curses_session() as stdscr: + oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr)) + oauth.authorize() if args.link: - page = SubmissionPage(stdscr, reddit, url=args.link) + page = SubmissionPage(stdscr, reddit, oauth, url=args.link) page.loop() subreddit = args.subreddit or 'front' - page = SubredditPage(stdscr, reddit, subreddit) + page = SubredditPage(stdscr, reddit, oauth, subreddit) page.loop() except praw.errors.InvalidUserPass: print('Invalid password for username: {}'.format(args.username)) + except praw.errors.OAuthAppRequired: + print('Invalid OAuth app config parameters') except requests.ConnectionError: print('Connection timeout') except requests.HTTPError: @@ -150,7 +168,6 @@ def main(): pass finally: # Ensure sockets are closed to prevent a ResourceWarning - print(reddit.is_oauth_session()) reddit.handler.http.close() sys.exit(main()) diff --git a/rtv/config.py b/rtv/config.py index c59a16d..e7761af 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -2,4 +2,13 @@ Global configuration settings """ -unicode = True \ No newline at end of file +unicode = True + +""" +OAuth settings +""" + +oauth_client_id = 'nxoobnwO7mCP5A' +oauth_client_secret = 'praw_gapfill' +oauth_redirect_uri = 'https://rtv.theo-piboubes.fr/auth' +oauth_scope = 'edit-history-identity-mysubreddits-privatemessages-read-report-save-submit-subscribe-vote' diff --git a/rtv/docs.py b/rtv/docs.py index 8775728..cefd7b0 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -1,6 +1,6 @@ from .__version__ import __version__ -__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE', +__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'OAUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE', 'SUBMISSION_FILE', 'COMMENT_EDIT_FILE'] AGENT = """\ @@ -17,6 +17,11 @@ Authenticating is required to vote and leave comments. If only a username is given, the program will display a secure prompt to enter a password. """ +OAUTH = """\ +Authentication is now done by OAuth, since PRAW will stop supporting login with +username and password soon. +""" + CONTROLS = """ Controls -------- diff --git a/rtv/oauth.py b/rtv/oauth.py index 868b69f..c5482b2 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,87 +1,145 @@ import configparser +import curses +import logging import os -import webbrowser +import time import uuid +import webbrowser -__all__ = [] +import praw -def get_config_file_path(): - HOME = os.path.expanduser('~') - XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), - os.path.join(HOME, '.rtv') - ] +from . import config +from .curses_helpers import show_notification, prompt_input - # get the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - break +__all__ = ['token_validity', 'OAuthTool'] +_logger = logging.getLogger(__name__) - return config_path +token_validity = 3540 -def load_oauth_config(): - config = configparser.ConfigParser() - config_path = get_config_file_path() - config.read(config_path) +class OAuthTool(object): - return config + def __init__(self, reddit, stdscr=None, loader=None, + client_id=None, redirect_uri=None, scope=None): + self.reddit = reddit + self.stdscr = stdscr + self.loader = loader -def read_setting(key, section='oauth'): - config = load_oauth_config() + self.config = configparser.ConfigParser() + self.config_fp = None - try: - setting = config[section][key] - except KeyError: - return None + self.client_id = client_id or config.oauth_client_id + # Comply with PRAW's desperate need for client secret + self.client_secret = config.oauth_client_secret + self.redirect_uri = redirect_uri or config.oauth_redirect_uri - return setting + self.scope = scope or config.oauth_scope.split('-') -def write_setting(key, value, section='oauth'): - config = load_oauth_config() + self.access_info = {} - config[section][key] = value - with open(config_path, 'w') as cfg_file: - config.write(cfg_file) + self.token_expiration = 0 -def authorize(reddit): - config = load_oauth_config() + def get_config_fp(self): + HOME = os.path.expanduser('~') + XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', + os.path.join(HOME, '.config')) - settings = {} - if config.has_section('oauth'): - settings = dict(config.items('oauth')) + config_paths = [ + os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), + os.path.join(HOME, '.rtv') + ] - scopes = ["edit", "history", "identity", "mysubreddits", "privatemessages", "read", "report", "save", "submit", "subscribe", "vote"] + # get the first existing config file + for config_path in config_paths: + if os.path.exists(config_path): + break - reddit.set_oauth_app_info(settings['client_id'], - settings['client_secret'], - settings['redirect_uri']) + return config_path - # Generate a random UUID - hex_uuid = uuid.uuid4().hex + def open_config(self, update=False): + if self.config_fp is None: + self.config_fp = self.get_config_fp() - permission_ask_page_link = reddit.get_authorize_url(str(hex_uuid), scope=scopes, refreshable=True) - input("You will now be redirected to your web browser. Press Enter to continue.") - webbrowser.open(permission_ask_page_link) + if update: + self.config.read(self.config_fp) - print("After allowing rtv app access, you will land on a page giving you a state and a code string. Please enter them here.") - final_state = input("State : ") - final_code = input("Code : ") + def save_config(self): + self.open_config() + with open(self.config_fp, 'w') as cfg: + self.config.write(cfg) - # Check if UUID matches obtained state - # (if not, authorization process is compromised, and I'm giving up) - if hex_uuid == final_state: - print("Obtained state matches UUID") - else: - print("Obtained state does not match UUID, stopping.") - return + def set_token_expiration(self): + self.token_expiration = time.time() + token_validity - # Get access information (authorization token) - info = reddit.get_access_information(final_code) - config['oauth']['authorization_token'] = info['access_token'] - config['oauth']['refresh_token'] = info['refresh_token'] - config['oauth']['scope'] = '-'.join(info['scope']) + def token_expired(self): + return time.time() > self.token_expiration - config_path = get_config_file_path() - with open(config_path, 'w') as cfg_file: - config.write(cfg_file) + def refresh(self, force=False): + if self.token_expired() or force: + try: + with self.loader(message='Refreshing token'): + new_access_info = self.reddit.refresh_access_information( + self.config['oauth']['refresh_token']) + self.access_info = new_access_info + self.reddit.set_access_credentials(scope=set(self.access_info['scope']), + access_token=self.access_info['access_token'], + refresh_token=self.access_info['refresh_token']) + self.set_token_expiration() + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e: + show_notification(self.stdscr, ['Invalid OAuth data']) + else: + self.config['oauth']['access_token'] = self.access_info['access_token'] + self.config['oauth']['refresh_token'] = self.access_info['refresh_token'] + self.save_config() + + def authorize(self): + self.reddit.set_oauth_app_info(self.client_id, + self.client_secret, + self.redirect_uri) + + self.open_config(update=True) + # If no previous OAuth data found, starting from scratch + if 'oauth' not in self.config or 'access_token' not in self.config['oauth']: + # Generate a random UUID + hex_uuid = uuid.uuid4().hex + + permission_ask_page_link = self.reddit.get_authorize_url(str(hex_uuid), + scope=self.scope, refreshable=True) + + webbrowser.open(permission_ask_page_link) + show_notification(self.stdscr, ['Access prompt opened in web browser']) + + final_state = prompt_input(self.stdscr, 'State: ') + final_code = prompt_input(self.stdscr, 'Code: ') + + if not final_state or not final_code: + curses.flash() + return + + # Check if UUID matches obtained state + # (if not, authorization process is compromised, and I'm giving up) + if hex_uuid != final_state: + show_notification(self.stdscr, ['UUID mismatch, stopping.']) + return + + # Get access information (tokens and scopes) + self.access_info = self.reddit.get_access_information(final_code) + + try: + with self.loader(message='Logging in'): + self.reddit.set_access_credentials( + scope=set(self.access_info['scope']), + access_token=self.access_info['access_token'], + refresh_token=self.access_info['refresh_token']) + self.set_token_expiration() + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e: + show_notification(self.stdscr, ['Invalid OAuth data']) + else: + if 'oauth' not in self.config: + self.config['oauth'] = {} + + self.config['oauth']['access_token'] = self.access_info['access_token'] + self.config['oauth']['refresh_token'] = self.access_info['refresh_token'] + self.save_config() + # Otherwise, fetch new access token + else: + self.refresh(force=True) diff --git a/rtv/page.py b/rtv/page.py index ef075d6..8f13565 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -12,6 +12,7 @@ from .helpers import open_editor from .curses_helpers import (Color, show_notification, show_help, prompt_input, add_line) from .docs import COMMENT_EDIT_FILE, SUBMISSION_FILE +from .oauth import OAuthTool __all__ = ['Navigator', 'BaseController', 'BasePage'] _logger = logging.getLogger(__name__) @@ -244,11 +245,12 @@ class BasePage(object): MIN_HEIGHT = 10 MIN_WIDTH = 20 - def __init__(self, stdscr, reddit, content, **kwargs): + def __init__(self, stdscr, reddit, content, oauth, **kwargs): self.stdscr = stdscr self.reddit = reddit self.content = content + self.oauth = oauth self.nav = Navigator(self.content.get, **kwargs) self._header_window = None @@ -312,6 +314,9 @@ class BasePage(object): @BaseController.register('a') def upvote(self): + # Refresh access token if expired + self.oauth.refresh() + data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -327,6 +332,9 @@ class BasePage(object): @BaseController.register('z') def downvote(self): + # Refresh access token if expired + self.oauth.refresh() + data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -348,23 +356,11 @@ class BasePage(object): account. """ - if self.reddit.is_logged_in(): - self.logout() + if self.reddit.is_oauth_session(): + self.reddit.clear_authentication() return - username = prompt_input(self.stdscr, 'Enter username:') - password = prompt_input(self.stdscr, 'Enter password:', hide=True) - if not username or not password: - curses.flash() - return - - try: - with self.loader(message='Logging in'): - self.reddit.login(username, password) - except praw.errors.InvalidUserPass: - show_notification(self.stdscr, ['Invalid user/pass']) - else: - show_notification(self.stdscr, ['Welcome {}'.format(username)]) + self.oauth.authorize() @BaseController.register('d') def delete(self): @@ -372,10 +368,13 @@ class BasePage(object): Delete a submission or comment. """ - if not self.reddit.is_logged_in(): + if not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return + # Refresh access token if expired + self.oauth.refresh() + data = self.content.get(self.nav.absolute_index) if data.get('author') != self.reddit.user.name: curses.flash() @@ -400,10 +399,13 @@ class BasePage(object): Edit a submission or comment. """ - if not self.reddit.is_logged_in(): + if not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return + # Refresh access token if expired + self.oauth.refresh() + data = self.content.get(self.nav.absolute_index) if data.get('author') != self.reddit.user.name: curses.flash() @@ -437,6 +439,10 @@ class BasePage(object): """ Checks the inbox for unread messages and displays a notification. """ + + # Refresh access token if expired + self.oauth.refresh() + inbox = len(list(self.reddit.get_unread(limit=1))) try: if inbox > 0: diff --git a/rtv/submission.py b/rtv/submission.py index 8731f0a..1086ce6 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -20,10 +20,11 @@ class SubmissionController(BaseController): class SubmissionPage(BasePage): - def __init__(self, stdscr, reddit, url=None, submission=None): + def __init__(self, stdscr, reddit, oauth, url=None, submission=None): self.controller = SubmissionController(self) self.loader = LoadScreen(stdscr) + self.oauth = oauth if url: content = SubmissionContent.from_url(reddit, url, self.loader) elif submission: @@ -32,7 +33,7 @@ class SubmissionPage(BasePage): raise ValueError('Must specify url or submission') super(SubmissionPage, self).__init__(stdscr, reddit, - content, page_index=-1) + content, oauth, page_index=-1) def loop(self): "Main control loop" @@ -88,10 +89,13 @@ class SubmissionPage(BasePage): selected comment. """ - if not self.reddit.is_logged_in(): + if not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return + # Refresh access token if expired + self.oauth.refresh() + data = self.content.get(self.nav.absolute_index) if data['type'] == 'Submission': content = data['text'] @@ -127,6 +131,9 @@ class SubmissionPage(BasePage): def delete_comment(self): "Delete a comment as long as it is not the current submission" + # Refresh access token if expired + self.oauth.refresh() + if self.nav.absolute_index != -1: self.delete() else: diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 7fc44e5..fcd1f07 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -33,13 +33,14 @@ class SubredditController(BaseController): class SubredditPage(BasePage): - def __init__(self, stdscr, reddit, name): + def __init__(self, stdscr, reddit, oauth, name): self.controller = SubredditController(self) self.loader = LoadScreen(stdscr) + self.oauth = oauth content = SubredditContent.from_name(reddit, name, self.loader) - super(SubredditPage, self).__init__(stdscr, reddit, content) + super(SubredditPage, self).__init__(stdscr, reddit, content, oauth) def loop(self): "Main control loop" @@ -53,6 +54,9 @@ class SubredditPage(BasePage): def refresh_content(self, name=None, order=None): "Re-download all submissions and reset the page index" + # Refresh access token if expired + self.oauth.refresh() + name = name or self.content.name order = order or self.content.order @@ -104,7 +108,7 @@ class SubredditPage(BasePage): "Select the current submission to view posts" data = self.content.get(self.nav.absolute_index) - page = SubmissionPage(self.stdscr, self.reddit, url=data['permalink']) + page = SubmissionPage(self.stdscr, self.reddit, self.oauth, url=data['permalink']) page.loop() if data['url_type'] == 'selfpost': global history @@ -119,7 +123,7 @@ class SubredditPage(BasePage): global history history.add(url) if data['url_type'] in ['x-post', 'selfpost']: - page = SubmissionPage(self.stdscr, self.reddit, url=url) + page = SubmissionPage(self.stdscr, self.reddit, self.oauth, url=url) page.loop() else: open_browser(url) @@ -128,10 +132,13 @@ class SubredditPage(BasePage): def post_submission(self): "Post a new submission to the given subreddit" - if not self.reddit.is_logged_in(): + if not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return + # Refresh access token if expired + self.oauth.refresh() + # Strips the subreddit to just the name # Make sure it is a valid subreddit for submission subreddit = self.reddit.get_subreddit(self.content.name) @@ -161,7 +168,7 @@ class SubredditPage(BasePage): time.sleep(2.0) # Open the newly created post s.catch = False - page = SubmissionPage(self.stdscr, self.reddit, submission=post) + page = SubmissionPage(self.stdscr, self.reddit, self.oauth, submission=post) page.loop() self.refresh_content() @@ -169,12 +176,15 @@ class SubredditPage(BasePage): def open_subscriptions(self): "Open user subscriptions page" - if not self.reddit.is_logged_in() and not self.reddit.is_oauth_session(): + if not self.reddit.is_oauth_session(): show_notification(self.stdscr, ['Not logged in']) return + # Refresh access token if expired + self.oauth.refresh() + # Open subscriptions page - page = SubscriptionPage(self.stdscr, self.reddit) + page = SubscriptionPage(self.stdscr, self.reddit, self.oauth) page.loop() # When user has chosen a subreddit in the subscriptions list, diff --git a/rtv/subscriptions.py b/rtv/subscriptions.py index ae8981e..64e3a2a 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscriptions.py @@ -15,14 +15,15 @@ class SubscriptionController(BaseController): class SubscriptionPage(BasePage): - def __init__(self, stdscr, reddit): + def __init__(self, stdscr, reddit, oauth): self.controller = SubscriptionController(self) self.loader = LoadScreen(stdscr) + self.oauth = oauth self.selected_subreddit_data = None content = SubscriptionContent.from_user(reddit, self.loader) - super(SubscriptionPage, self).__init__(stdscr, reddit, content) + super(SubscriptionPage, self).__init__(stdscr, reddit, content, oauth) def loop(self): "Main control loop" @@ -37,6 +38,9 @@ class SubscriptionPage(BasePage): def refresh_content(self): "Re-download all subscriptions and reset the page index" + # Refresh access token if expired + self.oauth.refresh() + self.content = SubscriptionContent.from_user(self.reddit, self.loader) self.nav = Navigator(self.content.get) From 4990a45c7b94c2b442e5b1f2e3f4a0713f988196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 17 Aug 2015 00:38:21 +0200 Subject: [PATCH 11/19] Removing unneeded arguments --- rtv/__main__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 043244e..232f5c4 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -91,11 +91,8 @@ def command_line(): group.add_argument('-p', dest='password', help='reddit password') oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH) - oauth_group.add_argument('--client-id', dest='client_id', help='OAuth app ID') - oauth_group.add_argument('--redurect-uri', dest='redirect_uri', help='OAuth app redirect URI') oauth_group.add_argument('--auth-token', dest='authorization_token', help='OAuth authorization token') oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') - oauth_group.add_argument('--scope', dest='scope', help='OAuth app scope') args = parser.parse_args() From 46dda7a2bc300d0408ab69970b23c6761735fbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 17 Aug 2015 02:25:34 +0200 Subject: [PATCH 12/19] No more default auto-login --- rtv/__main__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 232f5c4..c067771 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -91,7 +91,8 @@ def command_line(): group.add_argument('-p', dest='password', help='reddit password') oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH) - oauth_group.add_argument('--auth-token', dest='authorization_token', help='OAuth authorization token') + oauth_group.add_argument('--auto-login', dest='auto_login', help='OAuth auto-login setting') + oauth_group.add_argument('--auth-token', dest='access_token', help='OAuth authorization token') oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') args = parser.parse_args() @@ -139,7 +140,8 @@ def main(): reddit.config.decode_html_entities = False with curses_session() as stdscr: oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr)) - oauth.authorize() + if args.auto_login == 'True': + oauth.authorize() if args.link: page = SubmissionPage(stdscr, reddit, oauth, url=args.link) page.loop() From a15934e5e4359e798ab0862f1053288473725063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 17 Aug 2015 04:28:15 +0200 Subject: [PATCH 13/19] Default auto_login and README update --- README.rst | 12 ++++++++---- rtv/__main__.py | 38 +++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/README.rst b/README.rst index 59c68bc..f2394a7 100644 --- a/README.rst +++ b/README.rst @@ -152,14 +152,18 @@ RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CON Each line in the file will replace the corresponding default argument in the launch script. This can be used to avoid having to re-enter login credentials every time the program is launched. -Example config: +The OAuth section contains a boolean to trigger auto-login (defaults to False). +When authenticated, two additional fields are written : **access_token** and **refresh_token**. +Those are basically like username and password : they are used to authenticate you on Reddit servers. + +Example initial config: .. code-block:: ini - [rtv] - username=MyUsername - password=MySecretPassword + [oauth] + auto_login=False + [rtv] # Log file location log=/tmp/rtv.log diff --git a/rtv/__main__.py b/rtv/__main__.py index c067771..64b5a63 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -20,6 +20,23 @@ from .__version__ import __version__ __all__ = [] +def get_config_fp(): + HOME = os.path.expanduser('~') + XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', + os.path.join(HOME, '.config')) + + config_paths = [ + os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), + os.path.join(HOME, '.rtv') + ] + + # get the first existing config file + for config_path in config_paths: + if os.path.exists(config_path): + break + + return config_path + def open_config(): """ Search for a configuration file at the location ~/.rtv and attempt to load @@ -28,18 +45,8 @@ def open_config(): config = configparser.ConfigParser() - HOME = os.path.expanduser('~') - XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), - os.path.join(HOME, '.rtv') - ] - - # read only the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - config.read(config_path) - break + config_path = get_config_fp() + config.read(config_path) return config @@ -66,9 +73,14 @@ def load_oauth_config(): config = open_config() - defaults = {} if config.has_section('oauth'): defaults = dict(config.items('oauth')) + else: + # Populate OAuth section + config['oauth'] = {'auto_login': False} + with open(get_config_fp(), 'w') as cfg: + config.write(cfg) + defaults = dict(config.items('oauth')) return defaults From 84039aa061f41894d783fc400d767f9fbdc6622e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Wed, 19 Aug 2015 22:33:53 +0200 Subject: [PATCH 14/19] Revert to six configparser (compatibility module) --- rtv/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 64b5a63..021872e 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -7,7 +7,7 @@ import logging import requests import praw import praw.errors -import configparser +from six.moves import configparser from . import config from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError @@ -164,6 +164,8 @@ def main(): print('Invalid password for username: {}'.format(args.username)) except praw.errors.OAuthAppRequired: print('Invalid OAuth app config parameters') + except praw.errors.OAuthInvalidToken: + print('Invalid OAuth token') except requests.ConnectionError: print('Connection timeout') except requests.HTTPError: From b25b533783008de1a89e6fb2a84b8d4225246d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Thu, 20 Aug 2015 00:49:25 +0200 Subject: [PATCH 15/19] Bundling webserver into RTV --- MANIFEST.in | 1 + rtv/__main__.py | 4 +-- rtv/config.py | 2 +- rtv/oauth.py | 67 ++++++++++++++++++++++++++++++++++++++++--------- setup.py | 2 +- 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index fdc4d5e..02ad4bd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include version.py include CHANGELOG.rst CONTRIBUTORS.rst LICENSE +include rtv/templates/*.html diff --git a/rtv/__main__.py b/rtv/__main__.py index 021872e..bb9337d 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -152,7 +152,7 @@ def main(): reddit.config.decode_html_entities = False with curses_session() as stdscr: oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr)) - if args.auto_login == 'True': + if args.auto_login == 'True': # Ew! oauth.authorize() if args.link: page = SubmissionPage(stdscr, reddit, oauth, url=args.link) @@ -160,8 +160,6 @@ def main(): subreddit = args.subreddit or 'front' page = SubredditPage(stdscr, reddit, oauth, subreddit) page.loop() - except praw.errors.InvalidUserPass: - print('Invalid password for username: {}'.format(args.username)) except praw.errors.OAuthAppRequired: print('Invalid OAuth app config parameters') except praw.errors.OAuthInvalidToken: diff --git a/rtv/config.py b/rtv/config.py index e7761af..53f4c59 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -10,5 +10,5 @@ OAuth settings oauth_client_id = 'nxoobnwO7mCP5A' oauth_client_secret = 'praw_gapfill' -oauth_redirect_uri = 'https://rtv.theo-piboubes.fr/auth' +oauth_redirect_uri = 'http://127.0.0.1:65000/auth' oauth_scope = 'edit-history-identity-mysubreddits-privatemessages-read-report-save-submit-subscribe-vote' diff --git a/rtv/oauth.py b/rtv/oauth.py index c5482b2..3377fc2 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,4 +1,4 @@ -import configparser +from six.moves import configparser import curses import logging import os @@ -11,11 +11,37 @@ import praw from . import config from .curses_helpers import show_notification, prompt_input +from tornado import ioloop, web + __all__ = ['token_validity', 'OAuthTool'] _logger = logging.getLogger(__name__) token_validity = 3540 +oauth_state = None +oauth_code = None +oauth_error = None + +class HomeHandler(web.RequestHandler): + + def get(self): + self.render('home.html') + +class AuthHandler(web.RequestHandler): + + def get(self): + global oauth_state + global oauth_code + global oauth_error + + oauth_state = self.get_argument('state', default='state_placeholder') + oauth_code = self.get_argument('code', default='code_placeholder') + oauth_error = self.get_argument('error', default='error_placeholder') + + self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) + + ioloop.IOLoop.current().stop() + class OAuthTool(object): def __init__(self, reddit, stdscr=None, loader=None, @@ -38,6 +64,13 @@ class OAuthTool(object): self.token_expiration = 0 + # Initialize Tornado webapp and listen on port 65000 + self.callback_app = web.Application([ + (r'/', HomeHandler), + (r'/auth', AuthHandler), + ], template_path='rtv/templates') + self.callback_app.listen(65000) + def get_config_fp(self): HOME = os.path.expanduser('~') XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', @@ -105,27 +138,37 @@ class OAuthTool(object): permission_ask_page_link = self.reddit.get_authorize_url(str(hex_uuid), scope=self.scope, refreshable=True) - webbrowser.open(permission_ask_page_link) - show_notification(self.stdscr, ['Access prompt opened in web browser']) + with self.loader(message='Waiting for authorization'): + webbrowser.open(permission_ask_page_link) - final_state = prompt_input(self.stdscr, 'State: ') - final_code = prompt_input(self.stdscr, 'Code: ') + ioloop.IOLoop.current().start() - if not final_state or not final_code: - curses.flash() + global oauth_state + global oauth_code + global oauth_error + + self.final_state = oauth_state + self.final_code = oauth_code + self.final_error = oauth_error + + # Check if access was denied + if self.final_error == 'access_denied': + show_notification(self.stdscr, ['Declined access']) + return + elif self.final_error != 'error_placeholder': + show_notification(self.stdscr, ['Authentication error']) return - # Check if UUID matches obtained state # (if not, authorization process is compromised, and I'm giving up) - if hex_uuid != final_state: + elif hex_uuid != self.final_state: show_notification(self.stdscr, ['UUID mismatch, stopping.']) return - # Get access information (tokens and scopes) - self.access_info = self.reddit.get_access_information(final_code) - try: with self.loader(message='Logging in'): + # Get access information (tokens and scopes) + self.access_info = self.reddit.get_access_information(self.final_code) + self.reddit.set_access_credentials( scope=set(self.access_info['scope']), access_token=self.access_info['access_token'], diff --git a/setup.py b/setup.py index 48e5e1b..38d0d08 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( keywords='reddit terminal praw curses', packages=['rtv'], include_package_data=True, - install_requires=['praw>=3.1.0', 'six', 'requests', 'kitchen'], + install_requires=['tornado', 'praw>=3.1.0', 'six', 'requests', 'kitchen'], entry_points={'console_scripts': ['rtv=rtv.__main__:main']}, classifiers=[ 'Intended Audience :: End Users/Desktop', From a15eaee258d10fe58968daa9b7c6b1eaf639554b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Thu, 20 Aug 2015 00:55:28 +0200 Subject: [PATCH 16/19] Adding webapp HTML template files --- rtv/templates/auth.html | 15 +++++++++++++++ rtv/templates/home.html | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 rtv/templates/auth.html create mode 100644 rtv/templates/home.html diff --git a/rtv/templates/auth.html b/rtv/templates/auth.html new file mode 100644 index 0000000..4cff2b6 --- /dev/null +++ b/rtv/templates/auth.html @@ -0,0 +1,15 @@ + +RTV OAuth +{% if error == 'access_denied' %} +

Declined rtv access

+

You chose to stop Reddit Terminal Viewer from accessing your account, it will continue in unauthenticated mode.
+ You can close this page.

+{% elif error != 'error_placeholder' %} +

Error : {{ error }}

+{% elif (state == 'state_placeholder' or code == 'code_placeholder') and error == 'error_placeholder' %} +

Wait...

+

This page is supposed to be a Reddit OAuth callback. You can't just come here hands in the pocket!

+{% else %} +

Allowed rtv access

+

Reddit Terminal Viewer will now log in. You can close this page.

+{% end %} diff --git a/rtv/templates/home.html b/rtv/templates/home.html new file mode 100644 index 0000000..0b9ebf5 --- /dev/null +++ b/rtv/templates/home.html @@ -0,0 +1,3 @@ + +OAuth helper +

Reddit Terminal Viewer OAuth helper

From 9e27bee7e6b1f861aee963c85a4e7df9504c16a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Thu, 20 Aug 2015 13:24:34 +0200 Subject: [PATCH 17/19] Quickfix --- rtv/oauth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index 3377fc2..3cf245d 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -158,9 +158,10 @@ class OAuthTool(object): elif self.final_error != 'error_placeholder': show_notification(self.stdscr, ['Authentication error']) return + # Check if UUID matches obtained state # (if not, authorization process is compromised, and I'm giving up) - elif hex_uuid != self.final_state: + if hex_uuid != self.final_state: show_notification(self.stdscr, ['UUID mismatch, stopping.']) return From e7ad6067d2ca68a5fb2c2a64145e7b2a29de7886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Thu, 20 Aug 2015 13:34:37 +0200 Subject: [PATCH 18/19] Error handling --- rtv/__main__.py | 7 +++---- rtv/oauth.py | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index bb9337d..27c0387 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -160,10 +160,9 @@ def main(): subreddit = args.subreddit or 'front' page = SubredditPage(stdscr, reddit, oauth, subreddit) page.loop() - except praw.errors.OAuthAppRequired: - print('Invalid OAuth app config parameters') - except praw.errors.OAuthInvalidToken: - print('Invalid OAuth token') + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken, + praw.errors.HTTPException) as e: + print('Invalid OAuth data') except requests.ConnectionError: print('Connection timeout') except requests.HTTPError: diff --git a/rtv/oauth.py b/rtv/oauth.py index 3cf245d..81399de 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -117,7 +117,8 @@ class OAuthTool(object): access_token=self.access_info['access_token'], refresh_token=self.access_info['refresh_token']) self.set_token_expiration() - except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e: + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken, + praw.errors.HTTPException) as e: show_notification(self.stdscr, ['Invalid OAuth data']) else: self.config['oauth']['access_token'] = self.access_info['access_token'] From a2fa9c5ea3eb3d419c777dad04d6a42081f97a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Thu, 20 Aug 2015 14:39:45 +0200 Subject: [PATCH 19/19] Fix ResourceWarnings --- rtv/__main__.py | 4 ++++ rtv/oauth.py | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 27c0387..881feff 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -18,6 +18,8 @@ from .docs import * from .oauth import OAuthTool from .__version__ import __version__ +from tornado import ioloop + __all__ = [] def get_config_fp(): @@ -179,5 +181,7 @@ def main(): finally: # Ensure sockets are closed to prevent a ResourceWarning reddit.handler.http.close() + # Explicitly close file descriptors opened by Tornado's IOLoop + ioloop.IOLoop.current().close(all_fds=True) sys.exit(main()) diff --git a/rtv/oauth.py b/rtv/oauth.py index 81399de..0d3029c 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -141,7 +141,6 @@ class OAuthTool(object): with self.loader(message='Waiting for authorization'): webbrowser.open(permission_ask_page_link) - ioloop.IOLoop.current().start() global oauth_state