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 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 From d2822ccf8522a2dab9f451d801536a177d9fd38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 28 Aug 2015 20:28:58 +0200 Subject: [PATCH 13/36] Make OAuth compatible with Python 2 --- rtv/__main__.py | 5 +++-- rtv/oauth.py | 18 +++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 881feff..9970e4a 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -79,7 +79,8 @@ def load_oauth_config(): defaults = dict(config.items('oauth')) else: # Populate OAuth section - config['oauth'] = {'auto_login': False} + config.add_section('oauth') + config.set('oauth', 'auto_login', 'false') with open(get_config_fp(), 'w') as cfg: config.write(cfg) defaults = dict(config.items('oauth')) @@ -154,7 +155,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': # Ew! + if args.auto_login == 'true': # Ew! oauth.authorize() if args.link: page = SubmissionPage(stdscr, reddit, oauth, url=args.link) diff --git a/rtv/oauth.py b/rtv/oauth.py index 0d3029c..f7d64cf 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,4 +1,3 @@ -from six.moves import configparser import curses import logging import os @@ -7,6 +6,7 @@ import uuid import webbrowser import praw +from six.moves import configparser from . import config from .curses_helpers import show_notification, prompt_input @@ -111,7 +111,7 @@ class OAuthTool(object): try: with self.loader(message='Refreshing token'): new_access_info = self.reddit.refresh_access_information( - self.config['oauth']['refresh_token']) + self.config.get('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'], @@ -121,8 +121,8 @@ class OAuthTool(object): praw.errors.HTTPException) 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.config.set('oauth', 'access_token', self.access_info['access_token']) + self.config.set('oauth', 'refresh_token', self.access_info['refresh_token']) self.save_config() def authorize(self): @@ -132,7 +132,7 @@ class OAuthTool(object): 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']: + if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'access_token'): # Generate a random UUID hex_uuid = uuid.uuid4().hex @@ -178,11 +178,11 @@ class OAuthTool(object): 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'] = {} + if not self.config.has_section('oauth'): + self.config.add_section('oauth') - self.config['oauth']['access_token'] = self.access_info['access_token'] - self.config['oauth']['refresh_token'] = self.access_info['refresh_token'] + self.config.set('oauth', 'access_token', self.access_info['access_token']) + self.config.set('oauth', 'refresh_token', self.access_info['refresh_token']) self.save_config() # Otherwise, fetch new access token else: From c579aa928dad7cefc07027668b005a4c9eb35ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 28 Aug 2015 20:40:40 +0200 Subject: [PATCH 14/36] Additional Python 2 instructions --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index f2394a7..fd14260 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,13 @@ The installation will place a script in the system path $ rtv $ rtv --help +If you're having issues running RTV with Python 2, run RTV as module : + +.. code-block:: bash + + $ cd /path/to/rtv + $ python2 -m rtv + ===== Usage ===== From d24c81bce6ef2353e7a408377c4a5362d8799d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 28 Aug 2015 21:13:57 +0200 Subject: [PATCH 15/36] External OAuth configuration file --- README.rst | 19 +++++++++++++------ rtv/__main__.py | 50 ++++++++++++++++++++++++------------------------- rtv/oauth.py | 4 ++-- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index fd14260..a32e6b1 100644 --- a/README.rst +++ b/README.rst @@ -155,20 +155,21 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas Config File ----------- -RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME``). -Each line in the file will replace the corresponding default argument in the launch script. +RTV will read two configuration files: +* ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME/.rtv``) +* ``~/.config/rtv/oauth.cfg`` (or ``$XDG_CONFIG_HOME/.rtv-oauth``) +Each line in the files 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. -The OAuth section contains a boolean to trigger auto-login (defaults to False). +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.cfg** - [oauth] - auto_login=False +.. code-block:: ini [rtv] # Log file location @@ -184,6 +185,12 @@ Example initial config: # This may be necessary for compatibility with some terminal browsers # ascii=True +**oauth.cfg** + +.. code-block:: ini + + [oauth] + auto_login=false ========= Changelog diff --git a/rtv/__main__.py b/rtv/__main__.py index 9970e4a..8d3c1f8 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -22,7 +22,13 @@ from tornado import ioloop __all__ = [] -def get_config_fp(): +def load_rtv_config(): + """ + Attempt to load saved settings for things like the username and password. + """ + + config = configparser.ConfigParser() + HOME = os.path.expanduser('~') XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) @@ -35,30 +41,9 @@ def get_config_fp(): # get the first existing config file for config_path in config_paths: if os.path.exists(config_path): + config.read(config_path) break - return config_path - -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. - """ - - config = configparser.ConfigParser() - - config_path = get_config_fp() - config.read(config_path) - - 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')) @@ -73,7 +58,22 @@ def load_oauth_config(): Attempt to load saved OAuth settings """ - config = 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', 'oauth.cfg'), + os.path.join(HOME, '.rtv-oauth') + ] + + # get the first existing config file + for config_path in config_paths: + if os.path.exists(config_path): + config.read(config_path) + break if config.has_section('oauth'): defaults = dict(config.items('oauth')) @@ -81,7 +81,7 @@ def load_oauth_config(): # Populate OAuth section config.add_section('oauth') config.set('oauth', 'auto_login', 'false') - with open(get_config_fp(), 'w') as cfg: + with open(config_path, 'w') as cfg: config.write(cfg) defaults = dict(config.items('oauth')) diff --git a/rtv/oauth.py b/rtv/oauth.py index f7d64cf..8fcd118 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -77,8 +77,8 @@ class OAuthTool(object): os.path.join(HOME, '.config')) config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), - os.path.join(HOME, '.rtv') + os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg'), + os.path.join(HOME, '.rtv-oauth') ] # get the first existing config file From 3fb4dc66bb1efa92acc416852268848d263a5acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 28 Aug 2015 21:15:49 +0200 Subject: [PATCH 16/36] README --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index a32e6b1..17628a3 100644 --- a/README.rst +++ b/README.rst @@ -156,8 +156,10 @@ Config File ----------- RTV will read two configuration files: + * ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME/.rtv``) * ``~/.config/rtv/oauth.cfg`` (or ``$XDG_CONFIG_HOME/.rtv-oauth``) + Each line in the files 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. From 314d2dbf2643bf51d7ec5110531ea69a5d8374df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Mon, 31 Aug 2015 20:37:02 +0200 Subject: [PATCH 17/36] OAuth config file improvements --- rtv/__main__.py | 14 +++++--------- rtv/oauth.py | 15 +++++---------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/rtv/__main__.py b/rtv/__main__.py index 8d3c1f8..ebd8d51 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -64,16 +64,12 @@ def load_oauth_config(): XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg'), - os.path.join(HOME, '.rtv-oauth') - ] + if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): + config_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') + else: + config_path = os.path.join(HOME, '.rtv-oauth') - # get the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - config.read(config_path) - break + config.read(config_path) if config.has_section('oauth'): defaults = dict(config.items('oauth')) diff --git a/rtv/oauth.py b/rtv/oauth.py index 8fcd118..8203de6 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -76,17 +76,12 @@ class OAuthTool(object): XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) - config_paths = [ - os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg'), - os.path.join(HOME, '.rtv-oauth') - ] + if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): + file_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') + else: + file_path = os.path.join(HOME, '.rtv-oauth') - # get the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - break - - return config_path + return file_path def open_config(self, update=False): if self.config_fp is None: From f6546aaf75592d05d85a14e62f270781f3113818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Tue, 1 Sep 2015 22:32:56 +0200 Subject: [PATCH 18/36] Let PRAW manage authentication --- rtv/__main__.py | 1 - rtv/oauth.py | 41 ++--------------------- rtv/page.py | 15 --------- rtv/submission.py | 6 ---- rtv/subreddit.py | 11 +----- rtv/{subscriptions.py => subscription.py} | 3 -- 6 files changed, 4 insertions(+), 73 deletions(-) rename rtv/{subscriptions.py => subscription.py} (97%) diff --git a/rtv/__main__.py b/rtv/__main__.py index ebd8d51..0096031 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -103,7 +103,6 @@ def command_line(): oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH) 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() diff --git a/rtv/oauth.py b/rtv/oauth.py index 8203de6..e6f0168 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -16,8 +16,6 @@ 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 @@ -62,8 +60,6 @@ class OAuthTool(object): self.access_info = {} - self.token_expiration = 0 - # Initialize Tornado webapp and listen on port 65000 self.callback_app = web.Application([ (r'/', HomeHandler), @@ -95,31 +91,6 @@ class OAuthTool(object): with open(self.config_fp, 'w') as cfg: self.config.write(cfg) - def set_token_expiration(self): - self.token_expiration = time.time() + token_validity - - def token_expired(self): - return time.time() > self.token_expiration - - 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.get('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, - praw.errors.HTTPException) as e: - show_notification(self.stdscr, ['Invalid OAuth data']) - else: - self.config.set('oauth', 'access_token', self.access_info['access_token']) - self.config.set('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, @@ -127,7 +98,7 @@ class OAuthTool(object): self.open_config(update=True) # If no previous OAuth data found, starting from scratch - if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'access_token'): + if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): # Generate a random UUID hex_uuid = uuid.uuid4().hex @@ -164,21 +135,15 @@ class OAuthTool(object): 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'], - 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 not self.config.has_section('oauth'): self.config.add_section('oauth') - self.config.set('oauth', 'access_token', self.access_info['access_token']) self.config.set('oauth', 'refresh_token', self.access_info['refresh_token']) self.save_config() # Otherwise, fetch new access token else: - self.refresh(force=True) + with self.loader(message='Logging in'): + self.reddit.refresh_access_information(self.config.get('oauth', 'refresh_token')) diff --git a/rtv/page.py b/rtv/page.py index 8f13565..9eb9025 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -314,9 +314,6 @@ 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: @@ -332,9 +329,6 @@ 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: @@ -372,9 +366,6 @@ class BasePage(object): 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() @@ -403,9 +394,6 @@ class BasePage(object): 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() @@ -440,9 +428,6 @@ 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 1086ce6..f5aba23 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -93,9 +93,6 @@ class SubmissionPage(BasePage): 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'] @@ -131,9 +128,6 @@ 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 fcd1f07..9359d1e 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -8,7 +8,7 @@ import requests from .exceptions import SubredditError, AccountError from .page import BasePage, Navigator, BaseController from .submission import SubmissionPage -from .subscriptions import SubscriptionPage +from .subscription import SubscriptionPage from .content import SubredditContent from .helpers import open_browser, open_editor, strip_subreddit_url from .docs import SUBMISSION_FILE @@ -54,9 +54,6 @@ 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 @@ -136,9 +133,6 @@ class SubredditPage(BasePage): 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) @@ -180,9 +174,6 @@ class SubredditPage(BasePage): 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, self.oauth) page.loop() diff --git a/rtv/subscriptions.py b/rtv/subscription.py similarity index 97% rename from rtv/subscriptions.py rename to rtv/subscription.py index 64e3a2a..0361427 100644 --- a/rtv/subscriptions.py +++ b/rtv/subscription.py @@ -38,9 +38,6 @@ 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 6933b35240586732f7160897aa03091922c35bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 4 Sep 2015 18:18:31 +0200 Subject: [PATCH 19/36] Avoid infinite loop if server crashes --- rtv/oauth.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index e6f0168..b81ddd1 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -28,17 +28,18 @@ class HomeHandler(web.RequestHandler): class AuthHandler(web.RequestHandler): def get(self): - global oauth_state - global oauth_code - global oauth_error + try: + 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') + 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() + self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) + finally: + ioloop.IOLoop.current().stop() class OAuthTool(object): From eaed5142080736a561051c656c4bdc3673c3abfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 4 Sep 2015 18:22:01 +0200 Subject: [PATCH 20/36] OAuth flow terminal web browser support --- rtv/oauth.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index b81ddd1..afa0c32 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -58,9 +58,11 @@ class OAuthTool(object): self.redirect_uri = redirect_uri or config.oauth_redirect_uri self.scope = scope or config.oauth_scope.split('-') - self.access_info = {} + # Terminal web browser + self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] + # Initialize Tornado webapp and listen on port 65000 self.callback_app = web.Application([ (r'/', HomeHandler), @@ -93,6 +95,9 @@ class OAuthTool(object): self.config.write(cfg) def authorize(self): + if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']: + self.reddit.config.API_PATHS['authorize'] += '.compact' + self.reddit.set_oauth_app_info(self.client_id, self.client_secret, self.redirect_uri) @@ -106,9 +111,16 @@ class OAuthTool(object): permission_ask_page_link = self.reddit.get_authorize_url(str(hex_uuid), scope=self.scope, refreshable=True) - with self.loader(message='Waiting for authorization'): - webbrowser.open(permission_ask_page_link) + if self.compact: + show_notification(self.stdscr, ['Opening ' + os.environ.get('BROWSER')]) + curses.endwin() + webbrowser.open_new_tab(permission_ask_page_link) ioloop.IOLoop.current().start() + curses.doupdate() + else: + with self.loader(message='Waiting for authorization'): + webbrowser.open(permission_ask_page_link) + ioloop.IOLoop.current().start() global oauth_state global oauth_code From b643ce955909d248be6ee87665ee1f6d7e1799dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 4 Sep 2015 18:23:10 +0200 Subject: [PATCH 21/36] Complete logout --- rtv/oauth.py | 6 ++++++ rtv/page.py | 1 + 2 files changed, 7 insertions(+) diff --git a/rtv/oauth.py b/rtv/oauth.py index afa0c32..7edf7b5 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -94,6 +94,12 @@ class OAuthTool(object): with open(self.config_fp, 'w') as cfg: self.config.write(cfg) + def clear_oauth_data(self): + self.open_config(update=True) + if self.config.has_section('oauth') and self.config.has_option('oauth', 'refresh_token'): + self.config.remove_option('oauth', 'refresh_token') + self.save_config() + def authorize(self): if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']: self.reddit.config.API_PATHS['authorize'] += '.compact' diff --git a/rtv/page.py b/rtv/page.py index 9eb9025..34a5467 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -352,6 +352,7 @@ class BasePage(object): if self.reddit.is_oauth_session(): self.reddit.clear_authentication() + self.oauth.clear_oauth_data() return self.oauth.authorize() From 1f0ca4d59264bd1e924198d1cfe1ecececa8ef4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 4 Sep 2015 18:39:22 +0200 Subject: [PATCH 22/36] Update README.rst and docs --- README.rst | 24 ++++++++++++++---------- rtv/docs.py | 4 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 17628a3..7de1152 100644 --- a/README.rst +++ b/README.rst @@ -155,18 +155,10 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas Config File ----------- -RTV will read two configuration files: - -* ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME/.rtv``) -* ``~/.config/rtv/oauth.cfg`` (or ``$XDG_CONFIG_HOME/.rtv-oauth``) - +RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME``). Each line in the files 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. -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: **rtv.cfg** @@ -187,7 +179,19 @@ Example initial config: # This may be necessary for compatibility with some terminal browsers # ascii=True -**oauth.cfg** +----- +OAuth +----- + +OAuth is an authentication standard, that replaces authentication with login and password. + +RTV implements OAuth. It stores OAuth configuration at ``~/.config/rtv/oauth.cfg``(or ``$XDG_CONFIG_HOME``). +**The OAuth configuration file must be writable, and is created automatically if it doesn't exist.** +It contains a boolean to trigger auto-login (defaults to false). +When authenticated, an additional field is written : **refresh_token**. +This acts as a replacement to username and password : it is used to authenticate you on Reddit servers. + +Example **oauth.cfg**: .. code-block:: ini diff --git a/rtv/docs.py b/rtv/docs.py index cefd7b0..4f427c8 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -18,8 +18,8 @@ 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. +Authentication is now done by OAuth, since PRAW will drop +password authentication soon. """ CONTROLS = """ From 0134e157d083e18f7e1dcbbe663fdd8eb22fdc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Fri, 4 Sep 2015 20:09:58 +0200 Subject: [PATCH 23/36] Listen on port only when web server is needed --- rtv/oauth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index 7edf7b5..307cf4a 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -63,12 +63,11 @@ class OAuthTool(object): # Terminal web browser self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] - # Initialize Tornado webapp and listen on port 65000 + # Initialize Tornado webapp 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('~') @@ -111,6 +110,9 @@ class OAuthTool(object): self.open_config(update=True) # If no previous OAuth data found, starting from scratch if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): + # Start HTTP server and listen on port 65000 + self.callback_app.listen(65000) + # Generate a random UUID hex_uuid = uuid.uuid4().hex From 8e6758a38990ca7ea1ac6309c486c6121c2df78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sat, 5 Sep 2015 16:25:34 +0200 Subject: [PATCH 24/36] Open terminal web browser asynchronously --- rtv/oauth.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index 307cf4a..f696918 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -11,7 +11,8 @@ from six.moves import configparser from . import config from .curses_helpers import show_notification, prompt_input -from tornado import ioloop, web +from tornado import gen, ioloop, web +from concurrent.futures import ThreadPoolExecutor __all__ = ['token_validity', 'OAuthTool'] _logger = logging.getLogger(__name__) @@ -28,18 +29,15 @@ class HomeHandler(web.RequestHandler): class AuthHandler(web.RequestHandler): def get(self): - try: - global oauth_state - global oauth_code - global oauth_error + 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') + 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) - finally: - ioloop.IOLoop.current().stop() + self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) class OAuthTool(object): @@ -99,6 +97,13 @@ class OAuthTool(object): self.config.remove_option('oauth', 'refresh_token') self.save_config() + @gen.coroutine + def open_terminal_browser(self, url): + with ThreadPoolExecutor(max_workers=1) as executor: + yield executor.submit(webbrowser.open_new_tab, url) + + ioloop.IOLoop.current().stop() + def authorize(self): if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']: self.reddit.config.API_PATHS['authorize'] += '.compact' @@ -112,7 +117,7 @@ class OAuthTool(object): if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): # Start HTTP server and listen on port 65000 self.callback_app.listen(65000) - + # Generate a random UUID hex_uuid = uuid.uuid4().hex @@ -122,7 +127,7 @@ class OAuthTool(object): if self.compact: show_notification(self.stdscr, ['Opening ' + os.environ.get('BROWSER')]) curses.endwin() - webbrowser.open_new_tab(permission_ask_page_link) + ioloop.IOLoop.current().add_callback(self.open_terminal_browser, permission_ask_page_link) ioloop.IOLoop.current().start() curses.doupdate() else: From e90dcc6e5cd478e5f27a97520ccd4a0299bb9ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sat, 5 Sep 2015 16:29:31 +0200 Subject: [PATCH 25/36] Start HTTP server only once --- rtv/oauth.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rtv/oauth.py b/rtv/oauth.py index f696918..1dfcc67 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -11,7 +11,7 @@ from six.moves import configparser from . import config from .curses_helpers import show_notification, prompt_input -from tornado import gen, ioloop, web +from tornado import gen, ioloop, web, httpserver from concurrent.futures import ThreadPoolExecutor __all__ = ['token_validity', 'OAuthTool'] @@ -67,6 +67,8 @@ class OAuthTool(object): (r'/auth', AuthHandler), ], template_path='rtv/templates') + self.http_server = None + def get_config_fp(self): HOME = os.path.expanduser('~') XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', @@ -115,8 +117,9 @@ class OAuthTool(object): self.open_config(update=True) # If no previous OAuth data found, starting from scratch if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): - # Start HTTP server and listen on port 65000 - self.callback_app.listen(65000) + if self.http_server is None: + self.http_server = httpserver.HTTPServer(self.callback_app) + self.http_server.listen(65000) # Generate a random UUID hex_uuid = uuid.uuid4().hex From a84ac2f61e4ec6e81bcce926618eb01bcccf4eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sat, 5 Sep 2015 16:36:12 +0200 Subject: [PATCH 26/36] Stop IOLoop when callback page reached with GUI browser --- rtv/oauth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rtv/oauth.py b/rtv/oauth.py index 1dfcc67..6a51ada 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -28,6 +28,9 @@ class HomeHandler(web.RequestHandler): class AuthHandler(web.RequestHandler): + def initialize(self): + self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] + def get(self): global oauth_state global oauth_code @@ -39,6 +42,10 @@ class AuthHandler(web.RequestHandler): self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) + # Stop IOLoop if using BackgroundBrowser (or GUI browser) + if not self.compact: + ioloop.IOLoop.current().stop() + class OAuthTool(object): def __init__(self, reddit, stdscr=None, loader=None, From c8a2b480421f279f9df6aaf50967b60fb8fa09ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Piboub=C3=A8s?= Date: Sun, 6 Sep 2015 02:39:41 +0200 Subject: [PATCH 27/36] Python 2 concurrent.futures backport requirement --- setup.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 38d0d08..f00c25d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,14 @@ from setuptools import setup from version import __version__ as version +import sys + +requirements = ['tornado', 'praw>=3.1.0', 'six', 'requests', 'kitchen'] + +# Python 2: add required concurrent.futures backport from Python 3.2 +if sys.version_info.major <= 2: + requirements.append('futures') + setup( name='rtv', version=version, @@ -13,7 +21,7 @@ setup( keywords='reddit terminal praw curses', packages=['rtv'], include_package_data=True, - install_requires=['tornado', 'praw>=3.1.0', 'six', 'requests', 'kitchen'], + install_requires=requirements, entry_points={'console_scripts': ['rtv=rtv.__main__:main']}, classifiers=[ 'Intended Audience :: End Users/Desktop', From 6cc744bf91eeb199f63ea9236cac9ea7821ee59b Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 19:54:41 -0700 Subject: [PATCH 28/36] Tweaking a few things oauth things. --- MANIFEST.in | 2 +- rtv/__main__.py | 137 ++++++----------------- rtv/config.py | 58 ++++++++-- rtv/docs.py | 12 +- rtv/helpers.py | 38 ++++--- rtv/oauth.py | 235 +++++++++++++++------------------------- rtv/page.py | 9 +- rtv/submission.py | 5 +- rtv/subscription.py | 1 - rtv/templates/auth.html | 15 --- rtv/templates/home.html | 3 - templates/index.html | 43 ++++++++ 12 files changed, 244 insertions(+), 314 deletions(-) delete mode 100644 rtv/templates/auth.html delete mode 100644 rtv/templates/home.html create mode 100644 templates/index.html diff --git a/MANIFEST.in b/MANIFEST.in index 02ad4bd..1d70d56 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include version.py include CHANGELOG.rst CONTRIBUTORS.rst LICENSE -include rtv/templates/*.html +include rtv/templates/* diff --git a/rtv/__main__.py b/rtv/__main__.py index 0096031..3cc8c6c 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 tornado from . import config from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError @@ -18,70 +18,14 @@ from .docs import * from .oauth import OAuthTool from .__version__ import __version__ -from tornado import ioloop - __all__ = [] -def load_rtv_config(): - """ - Attempt to load saved settings for things like the username and password. - """ - - 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') - ] - - # get the first existing config file - for config_path in config_paths: - if os.path.exists(config_path): - config.read(config_path) - break - - defaults = {} - if config.has_section('rtv'): - defaults = dict(config.items('rtv')) - - if 'ascii' in defaults: - defaults['ascii'] = config.getboolean('rtv', 'ascii') - - return defaults - -def load_oauth_config(): - """ - Attempt to load saved OAuth settings - """ - - config = configparser.ConfigParser() - - HOME = os.path.expanduser('~') - XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', - os.path.join(HOME, '.config')) - - if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): - config_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') - else: - config_path = os.path.join(HOME, '.rtv-oauth') - - config.read(config_path) - - if config.has_section('oauth'): - defaults = dict(config.items('oauth')) - else: - # Populate OAuth section - config.add_section('oauth') - config.set('oauth', 'auto_login', 'false') - with open(config_path, 'w') as cfg: - config.write(cfg) - defaults = dict(config.items('oauth')) - - return defaults +# Pycharm debugging note: +# You can use pycharm to debug a curses application by launching rtv in a +# console window (python -m rtv) and using pycharm to attach to the remote +# process. On Ubuntu, you may need to allow ptrace permissions by setting +# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf. +# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails def command_line(): @@ -90,59 +34,46 @@ def command_line(): epilog=CONTROLS + HELP, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('-s', dest='subreddit', help='subreddit name') - parser.add_argument('-l', dest='link', help='full link to a submission') - parser.add_argument('--ascii', action='store_true', - help='enable ascii-only mode') - parser.add_argument('--log', metavar='FILE', action='store', - help='Log HTTP requests') - - group = parser.add_argument_group('authentication (optional)', AUTH) - 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('--auto-login', dest='auto_login', help='OAuth auto-login setting') - oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') - + parser.add_argument('-s', dest='subreddit', help='name of the subreddit that will be opened on start') + parser.add_argument('-l', dest='link', help='full URL of a submission that will be opened on start') + parser.add_argument('--ascii', action='store_true', help='enable ascii-only mode') + parser.add_argument('--log', metavar='FILE', action='store', help='log HTTP requests to a file') + parser.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') + parser.add_argument('--clear-session', dest='clear_session', action='store_true', help='Remove any saved OAuth tokens before starting') args = parser.parse_args() return args - def main(): "Main entry point" - # logging.basicConfig(level=logging.DEBUG, filename='rtv.log') + # Squelch SSL warnings + logging.captureWarnings(True) locale.setlocale(locale.LC_ALL, '') - args = command_line() - local_rtv_config = load_rtv_config() - local_oauth_config = load_oauth_config() - - # set the terminal title + # Set the terminal title title = 'rtv {0}'.format(__version__) if os.name == 'nt': os.system('title {0}'.format(title)) else: sys.stdout.write("\x1b]2;{0}\x07".format(title)) - # Fill in empty arguments with config file values. Paramaters explicitly + # Fill in empty arguments with config file values. Parameters explicitly # typed on the command line will take priority over config file params. - for key, val in local_rtv_config.items(): + args = command_line() + local_config = config.load_config() + for key, val in local_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 - logging.captureWarnings(True) + if args.ascii: + config.unicode = False if args.log: logging.basicConfig(level=logging.DEBUG, filename=args.log) + if args.clear_session: + config.clear_refresh_token() + if args.refresh_token: + config.save_refresh_token(args.refresh_token) try: print('Connecting...') @@ -150,21 +81,21 @@ def main(): reddit.config.decode_html_entities = False with curses_session() as stdscr: oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr)) - if args.auto_login == 'true': # Ew! + if oauth.refresh_token: oauth.authorize() + if args.link: page = SubmissionPage(stdscr, reddit, oauth, url=args.link) page.loop() subreddit = args.subreddit or 'front' page = SubredditPage(stdscr, reddit, oauth, subreddit) page.loop() - except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken, - praw.errors.HTTPException) as e: + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken): print('Invalid OAuth data') - except requests.ConnectionError: - print('Connection timeout') - except requests.HTTPError: + except praw.errors.NotFound: print('HTTP Error: 404 Not Found') + except praw.errors.HTTPException: + print('Connection timeout') except SubmissionError as e: print('Could not reach submission URL: {}'.format(e.url)) except SubredditError as e: @@ -178,6 +109,6 @@ def main(): # 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) + tornado.ioloop.IOLoop.current().close(all_fds=True) -sys.exit(main()) +sys.exit(main()) \ No newline at end of file diff --git a/rtv/config.py b/rtv/config.py index 53f4c59..24eeec0 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -1,14 +1,58 @@ """ Global configuration settings """ +import os +from six.moves import configparser + +HOME = os.path.expanduser('~') +XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) +CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') +TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token') unicode = True -""" -OAuth settings -""" - -oauth_client_id = 'nxoobnwO7mCP5A' +# https://github.com/reddit/reddit/wiki/OAuth2 +# Client ID is of type "installed app" and the secret should be left empty +oauth_client_id = 'E2oEtRQfdfAfNQ' oauth_client_secret = 'praw_gapfill' -oauth_redirect_uri = 'http://127.0.0.1:65000/auth' -oauth_scope = 'edit-history-identity-mysubreddits-privatemessages-read-report-save-submit-subscribe-vote' +oauth_redirect_uri = 'http://127.0.0.1:65000/' +oauth_redirect_port = 65000 +oauth_scope = ['edit', 'history', 'identity', 'mysubreddits', 'privatemessages', + 'read', 'report', 'save', 'submit', 'subscribe', 'vote'] + +def load_config(): + """ + Attempt to load settings from the local config file. + """ + + config = configparser.ConfigParser() + if os.path.exists(CONFIG): + config.read(CONFIG) + + config_dict = {} + if config.has_section('rtv'): + config_dict = dict(config.items('rtv')) + + # Convert 'true'/'false' to boolean True/False + if 'ascii' in config_dict: + config_dict['ascii'] = config.getboolean('rtv', 'ascii') + if 'clear_session' in config_dict: + config_dict['clear_session'] = config.getboolean('rtv', 'clear_session') + if 'oauth_scope' in config_dict: + config_dict['oauth_scope'] = config.oauth_scope.split('-') + return config_dict + +def load_refresh_token(filename=TOKEN): + if os.path.exists(filename): + with open(filename) as fp: + return fp.read().strip() + else: + return None + +def save_refresh_token(token, filename=TOKEN): + with open(filename, 'w+') as fp: + fp.write(token) + +def clear_refresh_token(filename=TOKEN): + if os.path.exists(filename): + os.remove(filename) \ No newline at end of file diff --git a/rtv/docs.py b/rtv/docs.py index a4cba11..66aa6c2 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -1,6 +1,6 @@ from .__version__ import __version__ -__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'OAUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE', +__all__ = ['AGENT', 'SUMMARY', 'CONTROLS', 'HELP', 'COMMENT_FILE', 'SUBMISSION_FILE', 'COMMENT_EDIT_FILE'] AGENT = """\ @@ -12,16 +12,6 @@ Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a terminal window. """ -AUTH = """\ -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 drop -password authentication soon. -""" - CONTROLS = """ Controls -------- diff --git a/rtv/helpers.py b/rtv/helpers.py index e07434e..274ad73 100644 --- a/rtv/helpers.py +++ b/rtv/helpers.py @@ -102,21 +102,7 @@ def open_browser(url): are not detected here. """ - console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx', 'w3m'] - - display = bool(os.environ.get("DISPLAY")) - - # Use the convention defined here to parse $BROWSER - # https://docs.python.org/2/library/webbrowser.html - if "BROWSER" in os.environ: - user_browser = os.environ["BROWSER"].split(os.pathsep)[0] - if user_browser in console_browsers: - display = False - - if webbrowser._tryorder and webbrowser._tryorder[0] in console_browsers: - display = False - - if display: + if check_browser_display(): command = "import webbrowser; webbrowser.open_new_tab('%s')" % url args = [sys.executable, '-c', command] with open(os.devnull, 'ab+', 0) as null: @@ -127,6 +113,28 @@ def open_browser(url): curses.doupdate() +def check_browser_display(): + """ + Use a number of methods to guess if the default webbrowser will open in + the background as opposed to opening directly in the terminal. + """ + + display = bool(os.environ.get("DISPLAY")) + + # Use the convention defined here to parse $BROWSER + # https://docs.python.org/2/library/webbrowser.html + console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx', 'w3m'] + if "BROWSER" in os.environ: + user_browser = os.environ["BROWSER"].split(os.pathsep)[0] + if user_browser in console_browsers: + display = False + + if webbrowser._tryorder and webbrowser._tryorder[0] in console_browsers: + display = False + + return display + + def wrap_text(text, width): """ Wrap text paragraphs to the given character width while preserving newlines. diff --git a/rtv/oauth.py b/rtv/oauth.py index 6a51ada..4bd6122 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,185 +1,120 @@ -import curses -import logging import os import time import uuid -import webbrowser import praw -from six.moves import configparser - -from . import config -from .curses_helpers import show_notification, prompt_input - from tornado import gen, ioloop, web, httpserver from concurrent.futures import ThreadPoolExecutor -__all__ = ['token_validity', 'OAuthTool'] -_logger = logging.getLogger(__name__) +from . import config +from .curses_helpers import show_notification, prompt_input +from .helpers import check_browser_display, open_browser + +__all__ = ['OAuthTool'] 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 initialize(self): - self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] - def get(self): - global oauth_state - global oauth_code - global oauth_error + global oauth_state, oauth_code, 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') + oauth_state = self.get_argument('state', default='placeholder') + oauth_code = self.get_argument('code', default='placeholder') + oauth_error = self.get_argument('error', default='placeholder') - self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error) + self.render('index.html', state=oauth_state, code=oauth_code, error=oauth_error) - # Stop IOLoop if using BackgroundBrowser (or GUI browser) - if not self.compact: + # Stop IOLoop if using a background browser such as firefox + if check_browser_display(): ioloop.IOLoop.current().stop() class OAuthTool(object): - def __init__(self, reddit, stdscr=None, loader=None, - client_id=None, redirect_uri=None, scope=None): + def __init__(self, reddit, stdscr=None, loader=None): + self.reddit = reddit self.stdscr = stdscr self.loader = loader - - self.config = configparser.ConfigParser() - self.config_fp = 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 - - self.scope = scope or config.oauth_scope.split('-') - self.access_info = {} - - # Terminal web browser - self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx'] - - # Initialize Tornado webapp - self.callback_app = web.Application([ - (r'/', HomeHandler), - (r'/auth', AuthHandler), - ], template_path='rtv/templates') - self.http_server = None - def get_config_fp(self): - HOME = os.path.expanduser('~') - XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', - os.path.join(HOME, '.config')) + self.refresh_token = config.load_refresh_token() - if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')): - file_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg') - else: - file_path = os.path.join(HOME, '.rtv-oauth') + # Initialize Tornado webapp + routes = [('/', AuthHandler)] + self.callback_app = web.Application(routes, template_path='templates') - return file_path + self.reddit.set_oauth_app_info(config.oauth_client_id, + config.oauth_client_secret, + config.oauth_redirect_uri) - def open_config(self, update=False): - if self.config_fp is None: - self.config_fp = self.get_config_fp() - - if update: - self.config.read(self.config_fp) - - def save_config(self): - self.open_config() - with open(self.config_fp, 'w') as cfg: - self.config.write(cfg) - - def clear_oauth_data(self): - self.open_config(update=True) - if self.config.has_section('oauth') and self.config.has_option('oauth', 'refresh_token'): - self.config.remove_option('oauth', 'refresh_token') - self.save_config() - - @gen.coroutine - def open_terminal_browser(self, url): - with ThreadPoolExecutor(max_workers=1) as executor: - yield executor.submit(webbrowser.open_new_tab, url) - - ioloop.IOLoop.current().stop() + # Reddit's mobile website works better on terminal browsers + if not check_browser_display(): + if '.compact' not in self.reddit.config.API_PATHS['authorize']: + self.reddit.config.API_PATHS['authorize'] += '.compact' def authorize(self): - if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']: - self.reddit.config.API_PATHS['authorize'] += '.compact' - 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 not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'): - if self.http_server is None: - self.http_server = httpserver.HTTPServer(self.callback_app) - self.http_server.listen(65000) - - # 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) - - if self.compact: - show_notification(self.stdscr, ['Opening ' + os.environ.get('BROWSER')]) - curses.endwin() - ioloop.IOLoop.current().add_callback(self.open_terminal_browser, permission_ask_page_link) - ioloop.IOLoop.current().start() - curses.doupdate() - else: - with self.loader(message='Waiting for authorization'): - webbrowser.open(permission_ask_page_link) - ioloop.IOLoop.current().start() - - 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 != self.final_state: - show_notification(self.stdscr, ['UUID mismatch, stopping.']) - return - - try: - with self.loader(message='Logging in'): - # Get access information (tokens and scopes) - self.access_info = self.reddit.get_access_information(self.final_code) - except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e: - show_notification(self.stdscr, ['Invalid OAuth data']) - else: - if not self.config.has_section('oauth'): - self.config.add_section('oauth') - - self.config.set('oauth', 'refresh_token', self.access_info['refresh_token']) - self.save_config() - # Otherwise, fetch new access token - else: + # If we already have a token, request new access credentials + if self.refresh_token: with self.loader(message='Logging in'): - self.reddit.refresh_access_information(self.config.get('oauth', 'refresh_token')) + self.reddit.refresh_access_information(self.refresh_token) + return + + # Start the authorization callback server + if self.http_server is None: + self.http_server = httpserver.HTTPServer(self.callback_app) + self.http_server.listen(config.oauth_redirect_port) + + hex_uuid = uuid.uuid4().hex + authorize_url = self.reddit.get_authorize_url( + hex_uuid, scope=config.oauth_scope, refreshable=True) + + # Open the browser and wait for the user to authorize the app + if check_browser_display(): + with self.loader(message='Waiting for authorization'): + open_browser(authorize_url) + ioloop.IOLoop.current().start() + else: + with self.loader(delay=0, message='Redirecting to reddit'): + # Provide user feedback + time.sleep(1) + ioloop.IOLoop.current().add_callback(self._open_authorize_url, + authorize_url) + ioloop.IOLoop.current().start() + + if oauth_error == 'access_denied': + show_notification(self.stdscr, ['Declined access']) + return + elif oauth_error != 'placeholder': + show_notification(self.stdscr, ['Authentication error']) + return + elif hex_uuid != oauth_state: + # Check if UUID matches obtained state. + # If not, authorization process is compromised. + show_notification(self.stdscr, ['UUID mismatch']) + return + + try: + with self.loader(message='Logging in'): + access_info = self.reddit.get_access_information(oauth_code) + config.save_refresh_token(access_info['refresh_token']) + self.refresh_token = access_info['refresh_token'] + except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken): + show_notification(self.stdscr, ['Invalid OAuth data']) + else: + message = ['Welcome {}!'.format(self.reddit.user.name)] + show_notification(self.stdscr, message) + + def clear_oauth_data(self): + self.reddit.clear_authentication() + config.clear_refresh_token() + self.refresh_token = None + + @gen.coroutine + def _open_authorize_url(self, url): + with ThreadPoolExecutor(max_workers=1) as executor: + yield executor.submit(open_browser, url) + ioloop.IOLoop.current().stop() diff --git a/rtv/page.py b/rtv/page.py index 34a5467..a51fb46 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -239,7 +239,7 @@ class BaseController(object): class BasePage(object): """ - Base terminal viewer incorperates a cursor to navigate content + Base terminal viewer incorporates a cursor to navigate content """ MIN_HEIGHT = 10 @@ -351,11 +351,10 @@ class BasePage(object): """ if self.reddit.is_oauth_session(): - self.reddit.clear_authentication() self.oauth.clear_oauth_data() - return - - self.oauth.authorize() + show_notification(self.stdscr, ['Logged out']) + else: + self.oauth.authorize() @BaseController.register('d') def delete(self): diff --git a/rtv/submission.py b/rtv/submission.py index f5aba23..9044bd5 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -24,7 +24,6 @@ class SubmissionPage(BasePage): self.controller = SubmissionController(self) self.loader = LoadScreen(stdscr) - self.oauth = oauth if url: content = SubmissionContent.from_url(reddit, url, self.loader) elif submission: @@ -32,8 +31,8 @@ class SubmissionPage(BasePage): else: raise ValueError('Must specify url or submission') - super(SubmissionPage, self).__init__(stdscr, reddit, - content, oauth, page_index=-1) + super(SubmissionPage, self).__init__(stdscr, reddit, content, oauth, + page_index=-1) def loop(self): "Main control loop" diff --git a/rtv/subscription.py b/rtv/subscription.py index 0361427..bf7b97c 100644 --- a/rtv/subscription.py +++ b/rtv/subscription.py @@ -19,7 +19,6 @@ class SubscriptionPage(BasePage): self.controller = SubscriptionController(self) self.loader = LoadScreen(stdscr) - self.oauth = oauth self.selected_subreddit_data = None content = SubscriptionContent.from_user(reddit, self.loader) diff --git a/rtv/templates/auth.html b/rtv/templates/auth.html deleted file mode 100644 index 4cff2b6..0000000 --- a/rtv/templates/auth.html +++ /dev/null @@ -1,15 +0,0 @@ - -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 deleted file mode 100644 index 0b9ebf5..0000000 --- a/rtv/templates/home.html +++ /dev/null @@ -1,3 +0,0 @@ - -OAuth helper -

Reddit Terminal Viewer OAuth helper

diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6a17b5f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,43 @@ + + + + RTV OAuth2 Helper + + + + + {% if error == 'access_denied' %} +

Access Denied


+

Reddit Terminal Viewer was denied access and will continue to operate in unauthenticated mode, you can close this window. + {% elif error != 'placeholder' %} +

Error : {{ error }}

+ {% elif (state == 'placeholder' or code == 'placeholder') %} +

Wait...


+

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

+ {% else %} +

Access Granted


+

Reddit Terminal Viewer will now log in, you can close this window.

+ {% end %} + + + + From 80bf45078a41452d603104e95a0ebb53e9161c44 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 21:27:51 -0700 Subject: [PATCH 29/36] Moved comment on running rtv as a module to the FAQ section. --- README.rst | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 17dd810..4303ee5 100644 --- a/README.rst +++ b/README.rst @@ -46,13 +46,6 @@ The installation will place a script in the system path $ rtv $ rtv --help -If you're having issues running RTV with Python 2, run RTV as module : - -.. code-block:: bash - - $ cd /path/to/rtv - $ python2 -m rtv - ===== Usage ===== @@ -201,6 +194,21 @@ Example **oauth.cfg**: [oauth] auto_login=false + +=== +FAQ +=== + +How do I run the code directly using python? + This project is structured to be run as a python *module*. This means that in order to resolve imports you need to launch using python's ``-m`` flag. This method works for all versions of python. Follow the example below, which assumes that you have cloned the repository into the directory **~/rtv_project**. + + .. code-block:: bash + + $ cd ~/rtv_project + $ python2 -m rtv + $ python3 -m rtv + + ========= Changelog ========= From 44c4eeca62d1ea9bf88142c86dc31835067ccb12 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 22:56:24 -0700 Subject: [PATCH 30/36] Updated README, added "persistant" option to config and command line. --- README.rst | 45 +++++++++++++++++++++------------------------ rtv/__main__.py | 12 ++++++------ rtv/config.py | 3 ++- rtv/oauth.py | 3 ++- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/README.rst b/README.rst index 4303ee5..12a515c 100644 --- a/README.rst +++ b/README.rst @@ -73,11 +73,8 @@ Basic Commands Authenticated Commands ---------------------- -Some actions require that you be logged in to your reddit account. To log in you can either: - -1. provide your username as a command line argument ``-u`` (your password will be securely prompted), or -2. press ``u`` while inside of the program - +Some actions require that you be logged in to your reddit account. +You can log in by pressing ``u`` while inside of the program. Once you are logged in your username will appear in the top-right corner of the screen. :``a``/``z``: Upvote/downvote @@ -147,12 +144,27 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas $ export BROWSER=w3m +----- +OAuth +----- + +OAuth support allows you to use reddit to authenticate on non-reddit websites and applications [#]_. OAuth replaces the deprecated cookie-based username/password authentication. + +RTV's login process follows the steps below: + +1. You initiate a login by pressing the ``u`` key. +2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV. +3. RTV uses the generated token to login on your behalf. +4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this by setting ``persistant=False`` in your RTV config. + +.. [#] ``_ + ----------- Config File ----------- RTV will read a configuration placed at ``~/.config/rtv/rtv.cfg`` (or ``$XDG_CONFIG_HOME``). -Each line in the files will replace the corresponding default argument in the launch script. +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 initial config: @@ -175,24 +187,9 @@ Example initial config: # This may be necessary for compatibility with some terminal browsers # ascii=True ------ -OAuth ------ - -OAuth is an authentication standard, that replaces authentication with login and password. - -RTV implements OAuth. It stores OAuth configuration at ``~/.config/rtv/oauth.cfg``(or ``$XDG_CONFIG_HOME``). -**The OAuth configuration file must be writable, and is created automatically if it doesn't exist.** -It contains a boolean to trigger auto-login (defaults to false). -When authenticated, an additional field is written : **refresh_token**. -This acts as a replacement to username and password : it is used to authenticate you on Reddit servers. - -Example **oauth.cfg**: - -.. code-block:: ini - - [oauth] - auto_login=false + # Enable persistant storage of your authentication token + # This allows you to remain logged in when you restart the program + persistant=True === diff --git a/rtv/__main__.py b/rtv/__main__.py index 3cc8c6c..422f663 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -38,8 +38,8 @@ def command_line(): parser.add_argument('-l', dest='link', help='full URL of a submission that will be opened on start') parser.add_argument('--ascii', action='store_true', help='enable ascii-only mode') parser.add_argument('--log', metavar='FILE', action='store', help='log HTTP requests to a file') - parser.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token') - parser.add_argument('--clear-session', dest='clear_session', action='store_true', help='Remove any saved OAuth tokens before starting') + parser.add_argument('--non-persistant', dest='persistant', action='store_false', help='Forget all authenticated users when the program exits') + parser.add_argument('--clear-auth', dest='clear_auth', action='store_true', help='Remove any saved OAuth tokens before starting') args = parser.parse_args() return args @@ -68,12 +68,12 @@ def main(): if args.ascii: config.unicode = False + if not args.persistant: + config.persistant = False if args.log: logging.basicConfig(level=logging.DEBUG, filename=args.log) - if args.clear_session: + if args.clear_auth: config.clear_refresh_token() - if args.refresh_token: - config.save_refresh_token(args.refresh_token) try: print('Connecting...') @@ -111,4 +111,4 @@ def main(): # Explicitly close file descriptors opened by Tornado's IOLoop tornado.ioloop.IOLoop.current().close(all_fds=True) -sys.exit(main()) \ No newline at end of file +sys.exit(main()) diff --git a/rtv/config.py b/rtv/config.py index 24eeec0..6d681e1 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -10,6 +10,7 @@ CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token') unicode = True +persistant = True # https://github.com/reddit/reddit/wiki/OAuth2 # Client ID is of type "installed app" and the secret should be left empty @@ -55,4 +56,4 @@ def save_refresh_token(token, filename=TOKEN): def clear_refresh_token(filename=TOKEN): if os.path.exists(filename): - os.remove(filename) \ No newline at end of file + os.remove(filename) diff --git a/rtv/oauth.py b/rtv/oauth.py index 4bd6122..88e4978 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -100,8 +100,9 @@ class OAuthTool(object): try: with self.loader(message='Logging in'): access_info = self.reddit.get_access_information(oauth_code) - config.save_refresh_token(access_info['refresh_token']) self.refresh_token = access_info['refresh_token'] + if config.persistant: + config.save_refresh_token(access_info['refresh_token']) except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken): show_notification(self.stdscr, ['Invalid OAuth data']) else: From f0411fb1d385e700e99d0e179c3c23aa69ba2cb3 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:01:59 -0700 Subject: [PATCH 31/36] Spelling. --- README.rst | 6 +++--- rtv/__main__.py | 6 +++--- rtv/config.py | 2 +- rtv/oauth.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 12a515c..dbc6aa3 100644 --- a/README.rst +++ b/README.rst @@ -155,7 +155,7 @@ RTV's login process follows the steps below: 1. You initiate a login by pressing the ``u`` key. 2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV. 3. RTV uses the generated token to login on your behalf. -4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this by setting ``persistant=False`` in your RTV config. +4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this by setting ``persistent=False`` in your RTV config. .. [#] ``_ @@ -187,9 +187,9 @@ Example initial config: # This may be necessary for compatibility with some terminal browsers # ascii=True - # Enable persistant storage of your authentication token + # Enable persistent storage of your authentication token # This allows you to remain logged in when you restart the program - persistant=True + persistent=True === diff --git a/rtv/__main__.py b/rtv/__main__.py index 422f663..06d0e19 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -38,7 +38,7 @@ def command_line(): parser.add_argument('-l', dest='link', help='full URL of a submission that will be opened on start') parser.add_argument('--ascii', action='store_true', help='enable ascii-only mode') parser.add_argument('--log', metavar='FILE', action='store', help='log HTTP requests to a file') - parser.add_argument('--non-persistant', dest='persistant', action='store_false', help='Forget all authenticated users when the program exits') + parser.add_argument('--non-persistent', dest='persistent', action='store_false', help='Forget all authenticated users when the program exits') parser.add_argument('--clear-auth', dest='clear_auth', action='store_true', help='Remove any saved OAuth tokens before starting') args = parser.parse_args() @@ -68,8 +68,8 @@ def main(): if args.ascii: config.unicode = False - if not args.persistant: - config.persistant = False + if not args.persistent: + config.persistent = False if args.log: logging.basicConfig(level=logging.DEBUG, filename=args.log) if args.clear_auth: diff --git a/rtv/config.py b/rtv/config.py index 6d681e1..1eda2be 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -10,7 +10,7 @@ CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token') unicode = True -persistant = True +persistent = True # https://github.com/reddit/reddit/wiki/OAuth2 # Client ID is of type "installed app" and the secret should be left empty diff --git a/rtv/oauth.py b/rtv/oauth.py index 88e4978..62aee86 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -101,7 +101,7 @@ class OAuthTool(object): with self.loader(message='Logging in'): access_info = self.reddit.get_access_information(oauth_code) self.refresh_token = access_info['refresh_token'] - if config.persistant: + if config.persistent: config.save_refresh_token(access_info['refresh_token']) except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken): show_notification(self.stdscr, ['Invalid OAuth data']) From 8b80e4e622371dcd23f827b2b4b9450396ebeef9 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:05:20 -0700 Subject: [PATCH 32/36] Fixed config file parsing. --- rtv/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/rtv/config.py b/rtv/config.py index 1eda2be..999dfce 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -37,10 +37,11 @@ def load_config(): # Convert 'true'/'false' to boolean True/False if 'ascii' in config_dict: config_dict['ascii'] = config.getboolean('rtv', 'ascii') - if 'clear_session' in config_dict: - config_dict['clear_session'] = config.getboolean('rtv', 'clear_session') - if 'oauth_scope' in config_dict: - config_dict['oauth_scope'] = config.oauth_scope.split('-') + if 'clear_auth' in config_dict: + config_dict['clear_auth'] = config.getboolean('rtv', 'clear_auth') + if 'persistent' in config_dict: + config_dict['persistent'] = config.getboolean('rtv', 'persistent') + return config_dict def load_refresh_token(filename=TOKEN): From 4287ec1749273e1c8ee48741fd880e7999adddd7 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:08:46 -0700 Subject: [PATCH 33/36] Added missing function to __all__. --- rtv/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rtv/helpers.py b/rtv/helpers.py index 274ad73..e666cdc 100644 --- a/rtv/helpers.py +++ b/rtv/helpers.py @@ -14,7 +14,8 @@ from . import config from .exceptions import ProgramError __all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad', - 'strip_subreddit_url', 'humanize_timestamp', 'open_editor'] + 'strip_subreddit_url', 'humanize_timestamp', 'open_editor', + 'check_browser_display'] def clean(string, n_cols=None): From 2e3e99b7176904467c82616f13ee0159d81e1b59 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:14:49 -0700 Subject: [PATCH 34/36] Removed unused import. --- rtv/page.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rtv/page.py b/rtv/page.py index a51fb46..b4a4dde 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -12,7 +12,6 @@ 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__) From 6f431dc593d0977e1bc8665275e42715511b1066 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:30:17 -0700 Subject: [PATCH 35/36] README tweaks. --- README.rst | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index dbc6aa3..2e69c6c 100644 --- a/README.rst +++ b/README.rst @@ -144,20 +144,23 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas $ export BROWSER=w3m ------ -OAuth ------ +-------------- +Authentication +-------------- -OAuth support allows you to use reddit to authenticate on non-reddit websites and applications [#]_. OAuth replaces the deprecated cookie-based username/password authentication. +RTV use OAuth [#]_. to facilitate logging into your reddit user account. This means that you never have to give your username and password directly to RTV. -RTV's login process follows the steps below: +RTV's login process follows these steps: 1. You initiate a login by pressing the ``u`` key. 2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV. 3. RTV uses the generated token to login on your behalf. -4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this by setting ``persistent=False`` in your RTV config. +4. The token is stored on your computer at ``~/.config/rtv/refresh-token`` for future sessions. You can disable this behavior by setting ``persistent=False`` in your RTV config. + +Note that RTV no longer allows you to input your username/password directly. This method of cookie based authentication has been deprecated by reddit and will not be supported in future releases [#]_. .. [#] ``_ +.. [#] ``_ ----------- Config File From 0fd96e80f236b1edbc67f0015419bc122e5e777a Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 20 Sep 2015 23:33:08 -0700 Subject: [PATCH 36/36] More README tweaks. --- README.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 2e69c6c..72616a6 100644 --- a/README.rst +++ b/README.rst @@ -148,9 +148,7 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas Authentication -------------- -RTV use OAuth [#]_. to facilitate logging into your reddit user account. This means that you never have to give your username and password directly to RTV. - -RTV's login process follows these steps: +RTV use OAuth to facilitate logging into your reddit user account [#]_. The login process follows these steps: 1. You initiate a login by pressing the ``u`` key. 2. You're redirected to a webbrowser where reddit will ask you to login and authorize RTV.