merge
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
include version.py
|
include version.py
|
||||||
include CHANGELOG.rst CONTRIBUTORS.rst LICENSE
|
include CHANGELOG.rst CONTRIBUTORS.rst LICENSE
|
||||||
|
<<<<<<< HEAD
|
||||||
include rtv.1
|
include rtv.1
|
||||||
|
=======
|
||||||
|
include rtv/templates/*
|
||||||
|
>>>>>>> 28d17b28d0840f75386586686897e9316378150e
|
||||||
|
|||||||
48
README.rst
48
README.rst
@@ -73,11 +73,8 @@ Basic Commands
|
|||||||
Authenticated Commands
|
Authenticated Commands
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
Some actions require that you be logged in to your reddit account. To log in you can either:
|
Some actions require that you be logged in to your reddit account.
|
||||||
|
You can log in by pressing ``u`` while inside of the program.
|
||||||
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
|
|
||||||
|
|
||||||
Once you are logged in your username will appear in the top-right corner of the screen.
|
Once you are logged in your username will appear in the top-right corner of the screen.
|
||||||
|
|
||||||
:``a``/``z``: Upvote/downvote
|
:``a``/``z``: Upvote/downvote
|
||||||
@@ -147,6 +144,22 @@ If you prefer to stay in the terminal, use ``$BROWSER`` to specify a console-bas
|
|||||||
|
|
||||||
$ export BROWSER=w3m
|
$ export BROWSER=w3m
|
||||||
|
|
||||||
|
--------------
|
||||||
|
Authentication
|
||||||
|
--------------
|
||||||
|
|
||||||
|
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.
|
||||||
|
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 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 [#]_.
|
||||||
|
|
||||||
|
.. [#] `<https://github.com/reddit/reddit/wiki/OAuth2>`_
|
||||||
|
.. [#] `<https://www.reddit.com/r/redditdev/comments/2ujhkr/important_api_licensing_terms_clarified/>`_
|
||||||
|
|
||||||
-----------
|
-----------
|
||||||
Config File
|
Config File
|
||||||
-----------
|
-----------
|
||||||
@@ -155,14 +168,13 @@ 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.
|
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.
|
This can be used to avoid having to re-enter login credentials every time the program is launched.
|
||||||
|
|
||||||
Example config:
|
Example initial config:
|
||||||
|
|
||||||
|
**rtv.cfg**
|
||||||
|
|
||||||
.. code-block:: ini
|
.. code-block:: ini
|
||||||
|
|
||||||
[rtv]
|
[rtv]
|
||||||
username=MyUsername
|
|
||||||
password=MySecretPassword
|
|
||||||
|
|
||||||
# Log file location
|
# Log file location
|
||||||
log=/tmp/rtv.log
|
log=/tmp/rtv.log
|
||||||
|
|
||||||
@@ -176,6 +188,24 @@ Example config:
|
|||||||
# This may be necessary for compatibility with some terminal browsers
|
# This may be necessary for compatibility with some terminal browsers
|
||||||
# ascii=True
|
# ascii=True
|
||||||
|
|
||||||
|
# Enable persistent storage of your authentication token
|
||||||
|
# This allows you to remain logged in when you restart the program
|
||||||
|
persistent=True
|
||||||
|
|
||||||
|
|
||||||
|
===
|
||||||
|
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
|
Changelog
|
||||||
|
|||||||
104
rtv/__main__.py
104
rtv/__main__.py
@@ -7,48 +7,25 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
import praw
|
import praw
|
||||||
import praw.errors
|
import praw.errors
|
||||||
from six.moves import configparser
|
import tornado
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError
|
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 .submission import SubmissionPage
|
||||||
from .subreddit import SubredditPage
|
from .subreddit import SubredditPage
|
||||||
from .docs import *
|
from .docs import *
|
||||||
|
from .oauth import OAuthTool
|
||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
|
|
||||||
__all__ = []
|
__all__ = []
|
||||||
|
|
||||||
def load_config():
|
# Pycharm debugging note:
|
||||||
"""
|
# You can use pycharm to debug a curses application by launching rtv in a
|
||||||
Search for a configuration file at the location ~/.rtv and attempt to load
|
# console window (python -m rtv) and using pycharm to attach to the remote
|
||||||
saved settings for things like the username and password.
|
# 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
|
||||||
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
|
|
||||||
|
|
||||||
defaults = {}
|
|
||||||
if config.has_section('rtv'):
|
|
||||||
defaults = dict(config.items('rtv'))
|
|
||||||
|
|
||||||
if 'ascii' in defaults:
|
|
||||||
defaults['ascii'] = config.getboolean('rtv', 'ascii')
|
|
||||||
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def command_line():
|
def command_line():
|
||||||
|
|
||||||
@@ -57,71 +34,68 @@ def command_line():
|
|||||||
epilog=CONTROLS + HELP,
|
epilog=CONTROLS + HELP,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
|
||||||
parser.add_argument('-s', dest='subreddit', help='subreddit name')
|
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 link to a submission')
|
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',
|
parser.add_argument('--ascii', action='store_true', help='enable ascii-only mode')
|
||||||
help='enable ascii-only mode')
|
parser.add_argument('--log', metavar='FILE', action='store', help='log HTTP requests to a file')
|
||||||
parser.add_argument('--log', metavar='FILE', action='store',
|
parser.add_argument('--non-persistent', dest='persistent', action='store_false', help='Forget all authenticated users when the program exits')
|
||||||
help='Log HTTP requests')
|
parser.add_argument('--clear-auth', dest='clear_auth', action='store_true', help='Remove any saved OAuth tokens before starting')
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"Main entry point"
|
"Main entry point"
|
||||||
|
|
||||||
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
|
# Squelch SSL warnings
|
||||||
|
logging.captureWarnings(True)
|
||||||
locale.setlocale(locale.LC_ALL, '')
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
||||||
args = command_line()
|
# Set the terminal title
|
||||||
local_config = load_config()
|
|
||||||
|
|
||||||
# set the terminal title
|
|
||||||
title = 'rtv {0}'.format(__version__)
|
title = 'rtv {0}'.format(__version__)
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
os.system('title {0}'.format(title))
|
os.system('title {0}'.format(title))
|
||||||
else:
|
else:
|
||||||
sys.stdout.write("\x1b]2;{0}\x07".format(title))
|
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.
|
# typed on the command line will take priority over config file params.
|
||||||
|
args = command_line()
|
||||||
|
local_config = config.load_config()
|
||||||
for key, val in local_config.items():
|
for key, val in local_config.items():
|
||||||
if getattr(args, key, None) is None:
|
if getattr(args, key, None) is None:
|
||||||
setattr(args, key, val)
|
setattr(args, key, val)
|
||||||
|
|
||||||
config.unicode = (not args.ascii)
|
if args.ascii:
|
||||||
|
config.unicode = False
|
||||||
# Squelch SSL warnings for Ubuntu
|
if not args.persistent:
|
||||||
logging.captureWarnings(True)
|
config.persistent = False
|
||||||
if args.log:
|
if args.log:
|
||||||
logging.basicConfig(level=logging.DEBUG, filename=args.log)
|
logging.basicConfig(level=logging.DEBUG, filename=args.log)
|
||||||
|
if args.clear_auth:
|
||||||
|
config.clear_refresh_token()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print('Connecting...')
|
print('Connecting...')
|
||||||
reddit = praw.Reddit(user_agent=AGENT)
|
reddit = praw.Reddit(user_agent=AGENT)
|
||||||
reddit.config.decode_html_entities = False
|
reddit.config.decode_html_entities = False
|
||||||
if args.username:
|
|
||||||
# PRAW will prompt for password if it is None
|
|
||||||
reddit.login(args.username, args.password)
|
|
||||||
with curses_session() as stdscr:
|
with curses_session() as stdscr:
|
||||||
|
oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr))
|
||||||
|
if oauth.refresh_token:
|
||||||
|
oauth.authorize()
|
||||||
|
|
||||||
if args.link:
|
if args.link:
|
||||||
page = SubmissionPage(stdscr, reddit, url=args.link)
|
page = SubmissionPage(stdscr, reddit, oauth, url=args.link)
|
||||||
page.loop()
|
page.loop()
|
||||||
subreddit = args.subreddit or 'front'
|
subreddit = args.subreddit or 'front'
|
||||||
page = SubredditPage(stdscr, reddit, subreddit)
|
page = SubredditPage(stdscr, reddit, oauth, subreddit)
|
||||||
page.loop()
|
page.loop()
|
||||||
except praw.errors.InvalidUserPass:
|
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken):
|
||||||
print('Invalid password for username: {}'.format(args.username))
|
print('Invalid OAuth data')
|
||||||
except requests.ConnectionError:
|
except praw.errors.NotFound:
|
||||||
print('Connection timeout')
|
|
||||||
except requests.HTTPError:
|
|
||||||
print('HTTP Error: 404 Not Found')
|
print('HTTP Error: 404 Not Found')
|
||||||
|
except praw.errors.HTTPException:
|
||||||
|
print('Connection timeout')
|
||||||
except SubmissionError as e:
|
except SubmissionError as e:
|
||||||
print('Could not reach submission URL: {}'.format(e.url))
|
print('Could not reach submission URL: {}'.format(e.url))
|
||||||
except SubredditError as e:
|
except SubredditError as e:
|
||||||
@@ -134,5 +108,7 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
# Ensure sockets are closed to prevent a ResourceWarning
|
# Ensure sockets are closed to prevent a ResourceWarning
|
||||||
reddit.handler.http.close()
|
reddit.handler.http.close()
|
||||||
|
# Explicitly close file descriptors opened by Tornado's IOLoop
|
||||||
|
tornado.ioloop.IOLoop.current().close(all_fds=True)
|
||||||
|
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
"""
|
"""
|
||||||
Global configuration settings
|
Global configuration settings
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
unicode = True
|
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
|
||||||
|
persistent = True
|
||||||
|
|
||||||
|
# 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/'
|
||||||
|
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_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):
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
|
|
||||||
__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE',
|
__all__ = ['AGENT', 'SUMMARY', 'CONTROLS', 'HELP', 'COMMENT_FILE',
|
||||||
'SUBMISSION_FILE', 'COMMENT_EDIT_FILE']
|
'SUBMISSION_FILE', 'COMMENT_EDIT_FILE']
|
||||||
|
|
||||||
AGENT = """\
|
AGENT = """\
|
||||||
@@ -12,11 +12,6 @@ Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
|
|||||||
terminal window.
|
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
CONTROLS = """
|
CONTROLS = """
|
||||||
Controls
|
Controls
|
||||||
--------
|
--------
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from . import config
|
|||||||
from .exceptions import ProgramError
|
from .exceptions import ProgramError
|
||||||
|
|
||||||
__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad',
|
__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):
|
def clean(string, n_cols=None):
|
||||||
@@ -102,21 +103,7 @@ def open_browser(url):
|
|||||||
are not detected here.
|
are not detected here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx', 'w3m']
|
if check_browser_display():
|
||||||
|
|
||||||
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:
|
|
||||||
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
|
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
|
||||||
args = [sys.executable, '-c', command]
|
args = [sys.executable, '-c', command]
|
||||||
with open(os.devnull, 'ab+', 0) as null:
|
with open(os.devnull, 'ab+', 0) as null:
|
||||||
@@ -127,6 +114,28 @@ def open_browser(url):
|
|||||||
curses.doupdate()
|
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):
|
def wrap_text(text, width):
|
||||||
"""
|
"""
|
||||||
Wrap text paragraphs to the given character width while preserving newlines.
|
Wrap text paragraphs to the given character width while preserving newlines.
|
||||||
|
|||||||
121
rtv/oauth.py
Normal file
121
rtv/oauth.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import praw
|
||||||
|
from tornado import gen, ioloop, web, httpserver
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
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 AuthHandler(web.RequestHandler):
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
global oauth_state, oauth_code, oauth_error
|
||||||
|
|
||||||
|
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('index.html', state=oauth_state, code=oauth_code, error=oauth_error)
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
|
||||||
|
self.reddit = reddit
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.loader = loader
|
||||||
|
self.http_server = None
|
||||||
|
|
||||||
|
self.refresh_token = config.load_refresh_token()
|
||||||
|
|
||||||
|
# Initialize Tornado webapp
|
||||||
|
routes = [('/', AuthHandler)]
|
||||||
|
self.callback_app = web.Application(routes, template_path='templates')
|
||||||
|
|
||||||
|
self.reddit.set_oauth_app_info(config.oauth_client_id,
|
||||||
|
config.oauth_client_secret,
|
||||||
|
config.oauth_redirect_uri)
|
||||||
|
|
||||||
|
# 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 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.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)
|
||||||
|
self.refresh_token = access_info['refresh_token']
|
||||||
|
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'])
|
||||||
|
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()
|
||||||
30
rtv/page.py
30
rtv/page.py
@@ -238,17 +238,18 @@ class BaseController(object):
|
|||||||
|
|
||||||
class BasePage(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
|
MIN_HEIGHT = 10
|
||||||
MIN_WIDTH = 20
|
MIN_WIDTH = 20
|
||||||
|
|
||||||
def __init__(self, stdscr, reddit, content, **kwargs):
|
def __init__(self, stdscr, reddit, content, oauth, **kwargs):
|
||||||
|
|
||||||
self.stdscr = stdscr
|
self.stdscr = stdscr
|
||||||
self.reddit = reddit
|
self.reddit = reddit
|
||||||
self.content = content
|
self.content = content
|
||||||
|
self.oauth = oauth
|
||||||
self.nav = Navigator(self.content.get, **kwargs)
|
self.nav = Navigator(self.content.get, **kwargs)
|
||||||
|
|
||||||
self._header_window = None
|
self._header_window = None
|
||||||
@@ -348,23 +349,11 @@ class BasePage(object):
|
|||||||
account.
|
account.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.reddit.is_logged_in():
|
if self.reddit.is_oauth_session():
|
||||||
self.logout()
|
self.oauth.clear_oauth_data()
|
||||||
return
|
show_notification(self.stdscr, ['Logged out'])
|
||||||
|
|
||||||
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:
|
else:
|
||||||
show_notification(self.stdscr, ['Welcome {}'.format(username)])
|
self.oauth.authorize()
|
||||||
|
|
||||||
@BaseController.register('d')
|
@BaseController.register('d')
|
||||||
def delete(self):
|
def delete(self):
|
||||||
@@ -372,7 +361,7 @@ class BasePage(object):
|
|||||||
Delete a submission or comment.
|
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'])
|
show_notification(self.stdscr, ['Not logged in'])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -400,7 +389,7 @@ class BasePage(object):
|
|||||||
Edit a submission or comment.
|
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'])
|
show_notification(self.stdscr, ['Not logged in'])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -437,6 +426,7 @@ class BasePage(object):
|
|||||||
"""
|
"""
|
||||||
Checks the inbox for unread messages and displays a notification.
|
Checks the inbox for unread messages and displays a notification.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
inbox = len(list(self.reddit.get_unread(limit=1)))
|
inbox = len(list(self.reddit.get_unread(limit=1)))
|
||||||
try:
|
try:
|
||||||
if inbox > 0:
|
if inbox > 0:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class SubmissionController(BaseController):
|
|||||||
|
|
||||||
class SubmissionPage(BasePage):
|
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.controller = SubmissionController(self)
|
||||||
self.loader = LoadScreen(stdscr)
|
self.loader = LoadScreen(stdscr)
|
||||||
@@ -31,8 +31,8 @@ class SubmissionPage(BasePage):
|
|||||||
else:
|
else:
|
||||||
raise ValueError('Must specify url or submission')
|
raise ValueError('Must specify url or submission')
|
||||||
|
|
||||||
super(SubmissionPage, self).__init__(stdscr, reddit,
|
super(SubmissionPage, self).__init__(stdscr, reddit, content, oauth,
|
||||||
content, page_index=-1)
|
page_index=-1)
|
||||||
|
|
||||||
def loop(self):
|
def loop(self):
|
||||||
"Main control loop"
|
"Main control loop"
|
||||||
@@ -88,7 +88,7 @@ class SubmissionPage(BasePage):
|
|||||||
selected comment.
|
selected comment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.reddit.is_logged_in():
|
if not self.reddit.is_oauth_session():
|
||||||
show_notification(self.stdscr, ['Not logged in'])
|
show_notification(self.stdscr, ['Not logged in'])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import requests
|
|||||||
from .exceptions import SubredditError, AccountError
|
from .exceptions import SubredditError, AccountError
|
||||||
from .page import BasePage, Navigator, BaseController
|
from .page import BasePage, Navigator, BaseController
|
||||||
from .submission import SubmissionPage
|
from .submission import SubmissionPage
|
||||||
from .subscriptions import SubscriptionPage
|
from .subscription import SubscriptionPage
|
||||||
from .content import SubredditContent
|
from .content import SubredditContent
|
||||||
from .helpers import open_browser, open_editor, strip_subreddit_url
|
from .helpers import open_browser, open_editor, strip_subreddit_url
|
||||||
from .docs import SUBMISSION_FILE
|
from .docs import SUBMISSION_FILE
|
||||||
@@ -33,13 +33,14 @@ class SubredditController(BaseController):
|
|||||||
|
|
||||||
class SubredditPage(BasePage):
|
class SubredditPage(BasePage):
|
||||||
|
|
||||||
def __init__(self, stdscr, reddit, name):
|
def __init__(self, stdscr, reddit, oauth, name):
|
||||||
|
|
||||||
self.controller = SubredditController(self)
|
self.controller = SubredditController(self)
|
||||||
self.loader = LoadScreen(stdscr)
|
self.loader = LoadScreen(stdscr)
|
||||||
|
self.oauth = oauth
|
||||||
|
|
||||||
content = SubredditContent.from_name(reddit, name, self.loader)
|
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):
|
def loop(self):
|
||||||
"Main control loop"
|
"Main control loop"
|
||||||
@@ -104,7 +105,7 @@ class SubredditPage(BasePage):
|
|||||||
"Select the current submission to view posts"
|
"Select the current submission to view posts"
|
||||||
|
|
||||||
data = self.content.get(self.nav.absolute_index)
|
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()
|
page.loop()
|
||||||
if data['url_type'] == 'selfpost':
|
if data['url_type'] == 'selfpost':
|
||||||
global history
|
global history
|
||||||
@@ -119,7 +120,7 @@ class SubredditPage(BasePage):
|
|||||||
global history
|
global history
|
||||||
history.add(url)
|
history.add(url)
|
||||||
if data['url_type'] in ['x-post', 'selfpost']:
|
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()
|
page.loop()
|
||||||
else:
|
else:
|
||||||
open_browser(url)
|
open_browser(url)
|
||||||
@@ -128,7 +129,7 @@ class SubredditPage(BasePage):
|
|||||||
def post_submission(self):
|
def post_submission(self):
|
||||||
"Post a new submission to the given subreddit"
|
"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'])
|
show_notification(self.stdscr, ['Not logged in'])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ class SubredditPage(BasePage):
|
|||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
# Open the newly created post
|
# Open the newly created post
|
||||||
s.catch = False
|
s.catch = False
|
||||||
page = SubmissionPage(self.stdscr, self.reddit, submission=post)
|
page = SubmissionPage(self.stdscr, self.reddit, self.oauth, submission=post)
|
||||||
page.loop()
|
page.loop()
|
||||||
self.refresh_content()
|
self.refresh_content()
|
||||||
|
|
||||||
@@ -169,12 +170,12 @@ class SubredditPage(BasePage):
|
|||||||
def open_subscriptions(self):
|
def open_subscriptions(self):
|
||||||
"Open user subscriptions page"
|
"Open user subscriptions page"
|
||||||
|
|
||||||
if not self.reddit.is_logged_in():
|
if not self.reddit.is_oauth_session():
|
||||||
show_notification(self.stdscr, ['Not logged in'])
|
show_notification(self.stdscr, ['Not logged in'])
|
||||||
return
|
return
|
||||||
|
|
||||||
# Open subscriptions page
|
# Open subscriptions page
|
||||||
page = SubscriptionPage(self.stdscr, self.reddit)
|
page = SubscriptionPage(self.stdscr, self.reddit, self.oauth)
|
||||||
page.loop()
|
page.loop()
|
||||||
|
|
||||||
# When user has chosen a subreddit in the subscriptions list,
|
# When user has chosen a subreddit in the subscriptions list,
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ class SubscriptionController(BaseController):
|
|||||||
|
|
||||||
class SubscriptionPage(BasePage):
|
class SubscriptionPage(BasePage):
|
||||||
|
|
||||||
def __init__(self, stdscr, reddit):
|
def __init__(self, stdscr, reddit, oauth):
|
||||||
|
|
||||||
self.controller = SubscriptionController(self)
|
self.controller = SubscriptionController(self)
|
||||||
self.loader = LoadScreen(stdscr)
|
self.loader = LoadScreen(stdscr)
|
||||||
self.selected_subreddit_data = None
|
self.selected_subreddit_data = None
|
||||||
|
|
||||||
content = SubscriptionContent.from_user(reddit, self.loader)
|
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):
|
def loop(self):
|
||||||
"Main control loop"
|
"Main control loop"
|
||||||
@@ -37,7 +37,7 @@ class SubscriptionPage(BasePage):
|
|||||||
def refresh_content(self):
|
def refresh_content(self):
|
||||||
"Re-download all subscriptions and reset the page index"
|
"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)
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
@SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT)
|
@SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT)
|
||||||
@@ -70,4 +70,4 @@ class SubscriptionPage(BasePage):
|
|||||||
row = offset + 1
|
row = offset + 1
|
||||||
for row, text in enumerate(data['split_title'], start=row):
|
for row, text in enumerate(data['split_title'], start=row):
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
add_line(win, text, row, 1)
|
add_line(win, text, row, 1)
|
||||||
12
setup.py
12
setup.py
@@ -1,6 +1,14 @@
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
from version import __version__ as version
|
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(
|
setup(
|
||||||
name='rtv',
|
name='rtv',
|
||||||
version=version,
|
version=version,
|
||||||
@@ -13,8 +21,12 @@ setup(
|
|||||||
keywords='reddit terminal praw curses',
|
keywords='reddit terminal praw curses',
|
||||||
packages=['rtv'],
|
packages=['rtv'],
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
<<<<<<< HEAD
|
||||||
data_files=[("share/man/man1", ["rtv.1"])],
|
data_files=[("share/man/man1", ["rtv.1"])],
|
||||||
install_requires=['praw>=3.1.0', 'six', 'requests', 'kitchen'],
|
install_requires=['praw>=3.1.0', 'six', 'requests', 'kitchen'],
|
||||||
|
=======
|
||||||
|
install_requires=requirements,
|
||||||
|
>>>>>>> 28d17b28d0840f75386586686897e9316378150e
|
||||||
entry_points={'console_scripts': ['rtv=rtv.__main__:main']},
|
entry_points={'console_scripts': ['rtv=rtv.__main__:main']},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Intended Audience :: End Users/Desktop',
|
'Intended Audience :: End Users/Desktop',
|
||||||
|
|||||||
43
templates/index.html
Normal file
43
templates/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>RTV OAuth2 Helper</title>
|
||||||
|
<!-- style borrowed from http://bettermotherfuckingwebsite.com/ -->
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
margin:40px auto;
|
||||||
|
max-width:650px;
|
||||||
|
line-height:1.6;
|
||||||
|
font-size:18px;
|
||||||
|
font-family:Arial, Helvetica, sans-serif;
|
||||||
|
color:#444;
|
||||||
|
padding:0 10px;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
line-height:1.2
|
||||||
|
}
|
||||||
|
#footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
width: 100%;
|
||||||
|
font-size:14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if error == 'access_denied' %}
|
||||||
|
<h1 style="color: red">Access Denied</h1><hr>
|
||||||
|
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was denied access and will continue to operate in unauthenticated mode, you can close this window.
|
||||||
|
{% elif error != 'placeholder' %}
|
||||||
|
<h1 style="color: red">Error : {{ error }}</h1>
|
||||||
|
{% elif (state == 'placeholder' or code == 'placeholder') %}
|
||||||
|
<h1>Wait...</h1><hr>
|
||||||
|
<p>This page is supposed to be a Reddit OAuth callback. You can't just come here hands in your pocket!</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 style="color: green">Access Granted</h1><hr>
|
||||||
|
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> will now log in, you can close this window.</p>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
<div id="footer">View the <a href="http://www.github.com/michael-lazar/rtv">Documentation</a></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user