diff --git a/.travis.yml b/.travis.yml index 081d022..80cc630 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,7 @@ python: - "3.4" - "3.5" before_install: - - pip install coveralls pytest coverage mock pylint - - pip install git+https://github.com/kevin1024/vcrpy.git + - pip install coveralls pytest coverage mock pylint vcrpy install: - pip install . script: diff --git a/rtv/__main__.py b/rtv/__main__.py index e13acac..6d94366 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -9,7 +9,6 @@ import warnings import six import praw -import tornado import requests from . import docs @@ -158,7 +157,5 @@ def main(): # Ensure sockets are closed to prevent a ResourceWarning if 'reddit' in locals(): reddit.handler.http.close() - # Explicitly close file descriptors opened by Tornado's IOLoop - tornado.ioloop.IOLoop.current().close(all_fds=True) sys.exit(main()) diff --git a/rtv/config.py b/rtv/config.py index 27010bd..5fe3e0e 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -15,9 +15,9 @@ from .objects import KeyMap PACKAGE = os.path.dirname(__file__) HOME = os.path.expanduser('~') -TEMPLATE = os.path.join(PACKAGE, 'templates') -DEFAULT_CONFIG = os.path.join(TEMPLATE, 'rtv.cfg') -DEFAULT_MAILCAP = os.path.join(TEMPLATE, 'mailcap') +TEMPLATES = os.path.join(PACKAGE, 'templates') +DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg') +DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap') XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') MAILCAP = os.path.join(HOME, '.mailcap') diff --git a/rtv/docs.py b/rtv/docs.py index 79513f9..6bdf31a 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -103,3 +103,27 @@ SUBMISSION_EDIT_FILE = """{content} # # Editing {name} """ + +OAUTH_ACCESS_DENIED = """\ +
Reddit Terminal Viewer was + denied access and will continue to operate in unauthenticated mode, + you can close this window.
+""" + +OAUTH_ERROR = """\ +{error}
+""" + +OAUTH_INVALID = """\ +This page is supposed to be a Reddit OAuth callback. + You can't just come here hands in your pocket!
+""" + +OAUTH_SUCCESS = """\ +Reddit Terminal Viewer + will now log in, you can close this window.
+""" \ No newline at end of file diff --git a/rtv/oauth.py b/rtv/oauth.py index e18fdd7..b78e7f7 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -1,62 +1,118 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os import time import uuid +import string +import codecs +import logging +import threading -from concurrent.futures import ThreadPoolExecutor -from tornado import gen, ioloop, web, httpserver +#pylint: disable=import-error +from six.moves.urllib.parse import urlparse, parse_qs +from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -from .config import TEMPLATE +from . import docs +from .config import TEMPLATES -class OAuthHandler(web.RequestHandler): - """ - Intercepts the redirect that Reddit sends the user to after they verify or - deny the application access. +_logger = logging.getLogger(__name__) - The GET should supply 3 request params: - state: Unique id that was supplied by us at the beginning of the - process to verify that the session matches. - code: Code that we can use to generate the refresh token. - error: If an error occurred, it will be placed here. - """ +INDEX = os.path.join(TEMPLATES, 'index.html') - def initialize(self, display=None, params=None): - self.display = display - self.params = params - def get(self): - self.params['state'] = self.get_argument('state', default=None) - self.params['code'] = self.get_argument('code', default=None) - self.params['error'] = self.get_argument('error', default=None) +class OAuthHandler(BaseHTTPRequestHandler): - self.render('index.html', **self.params) + # params are stored as a global because we don't have control over what + # gets passed into the handler __init__. These will be accessed by the + # OAuthHelper class. + params = {'state': None, 'code': None, 'error': None} + shutdown_on_request = True - complete = self.params['state'] and self.params['code'] - if complete or self.params['error']: - # Stop IOLoop if using a background browser such as firefox - if self.display: - ioloop.IOLoop.current().stop() + def do_GET(self): + """ + Accepts GET requests to http://localhost:6500/, and stores the query + params in the global dict. If shutdown_on_request is true, stop the + server after the first successful request. + + The http request may contain the following query params: + - state : unique identifier, should match what we passed to reddit + - code : code that can be exchanged for a refresh token + - error : if provided, the OAuth error that occurred + """ + + parsed_path = urlparse(self.path) + if parsed_path.path != '/': + self.send_error(404) + + qs = parse_qs(parsed_path.query) + self.params['state'] = qs['state'][0] if 'state' in qs else None + self.params['code'] = qs['code'][0] if 'code' in qs else None + self.params['error'] = qs['error'][0] if 'error' in qs else None + + body = self.build_body() + + # send_response also sets the Server and Date headers + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=UTF-8') + self.send_header('Content-Length', len(body)) + self.end_headers() + + self.wfile.write(body) + + if self.shutdown_on_request: + # Shutdown the server after serving the request + # http://stackoverflow.com/a/22533929 + thread = threading.Thread(target=self.server.shutdown) + thread.daemon = True + thread.start() + + def log_message(self, format, *args): + """ + Redirect logging to our own handler instead of stdout + """ + _logger.debug(format, *args) + + def build_body(self, template_file=INDEX): + """ + Params: + template_file (text): Path to an index.html template + + Returns: + body (bytes): THe utf-8 encoded document body + """ + + if self.params['error'] == 'access_denied': + message = docs.OAUTH_ACCESS_DENIED + elif self.params['error'] is not None: + message = docs.OAUTH_ERROR.format(error=self.params['error']) + elif self.params['state'] is None or self.params['code'] is None: + message = docs.OAUTH_INVALID + else: + message = docs.OAUTH_SUCCESS + + with codecs.open(template_file, 'r', 'utf-8') as fp: + index_text = fp.read() + + body = string.Template(index_text).substitute(message=message) + body = codecs.encode(body, 'utf-8') + return body class OAuthHelper(object): + params = OAuthHandler.params + def __init__(self, reddit, term, config): self.term = term self.reddit = reddit self.config = config - self.http_server = None - self.params = {'state': None, 'code': None, 'error': None} - - # Initialize Tornado webapp - # Pass a mutable params object so the request handler can modify it - kwargs = {'display': self.term.display, 'params': self.params} - routes = [('/', OAuthHandler, kwargs)] - self.callback_app = web.Application( - routes, template_path=TEMPLATE) + # Wait to initialize the server, we don't want to reserve the port + # unless we know that the server needs to be used. + self.server = None self.reddit.set_oauth_app_info( self.config['oauth_client_id'], @@ -79,40 +135,53 @@ class OAuthHelper(object): self.config.refresh_token) return - # https://github.com/tornadoweb/tornado/issues/1420 - io = ioloop.IOLoop.current() - - # Start the authorization callback server - if self.http_server is None: - self.http_server = httpserver.HTTPServer(self.callback_app) - self.http_server.listen(self.config['oauth_redirect_port']) - state = uuid.uuid4().hex authorize_url = self.reddit.get_authorize_url( state, scope=self.config['oauth_scope'], refreshable=True) + if self.server is None: + address = ('', self.config['oauth_redirect_port']) + self.server = HTTPServer(address, OAuthHandler) + if self.term.display: # Open a background browser (e.g. firefox) which is non-blocking. - # Stop the iloop when the user hits the auth callback, at which - # point we continue and check the callback params. + # The server will block until it responds to its first request, + # at which point we can check the callback params. + OAuthHandler.shutdown_on_request = True with self.term.loader('Opening browser for authorization'): self.term.open_browser(authorize_url) - io.start() + self.server.serve_forever() if self.term.loader.exception: + # Don't need to call server.shutdown() because serve_forever() + # is wrapped in a try-finally that doees it for us. return else: # Open the terminal webbrowser in a background thread and wait # while for the user to close the process. Once the process is # closed, the iloop is stopped and we can check if the user has # hit the callback URL. + OAuthHandler.shutdown_on_request = False with self.term.loader('Redirecting to reddit', delay=0): # This load message exists to provide user feedback time.sleep(1) - io.add_callback(self._async_open_browser, authorize_url) - io.start() + + thread = threading.Thread(target=self.server.serve_forever) + thread.daemon = True + thread.start() + try: + self.term.open_browser(authorize_url) + except Exception as e: + # If an exception is raised it will be seen by the thread + # so we don't need to explicitly shutdown() the server + _logger.exception(e) + self.term.show_notification('Browser Error') + else: + self.server.shutdown() + finally: + thread.join() if self.params['error'] == 'access_denied': - self.term.show_notification('Declined access') + self.term.show_notification('Denied access') return elif self.params['error']: self.term.show_notification('Authentication error') @@ -138,10 +207,4 @@ class OAuthHelper(object): def clear_oauth_data(self): self.reddit.clear_authentication() - self.config.delete_refresh_token() - - @gen.coroutine - def _async_open_browser(self, url): - with ThreadPoolExecutor(max_workers=1) as executor: - yield executor.submit(self.term.open_browser, url) - ioloop.IOLoop.current().stop() \ No newline at end of file + self.config.delete_refresh_token() \ No newline at end of file diff --git a/rtv/templates/index.html b/rtv/templates/index.html index 8c3e275..20a0987 100644 --- a/rtv/templates/index.html +++ b/rtv/templates/index.html @@ -25,20 +25,7 @@ - {% if error == 'access_denied' %} -Reddit Terminal Viewer was denied access and will continue to operate in unauthenticated mode, you can close this window. - {% elif error is not None %} -
{{ error }}
- {% elif (state is None or code is None) %} -This page is supposed to be a Reddit OAuth callback. You can't just come here hands in your pocket!
- {% else %} -Reddit Terminal Viewer will now log in, you can close this window.
- {% end %} - +${message}