diff --git a/Pipfile b/Pipfile index 3ff9107..736121e 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,8 @@ python-dotenv = "*" click = "*" "e1839a8" = {path = ".", editable = true} appdirs = "*" +keyring = "*" +"keyrings.alt" = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 296356a..7934b71 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "add9770cb1b3cd200c03d312f2a977e024c75df453ca2f33094265959e1e7376" + "sha256": "823dc15b2e595d044b28df0bb1d28da5da1f2235baa200cf8d36c3eeb988a86d" }, "pipfile-spec": 6, "requires": { @@ -133,6 +133,14 @@ "editable": true, "path": "." }, + "entrypoints": { + "hashes": [ + "sha256:10ad569bb245e7e2ba425285b9fa3e8178a0dc92fc53b1e1c553805e15a8825b", + "sha256:d2d587dde06f99545fb13a383d2cd336a8ff1f359c5839ce3a64c917d10c029f" + ], + "markers": "python_version >= '2.7'", + "version": "==0.2.3" + }, "http-ece": { "hashes": [ "sha256:2f31a0640c31a0c2934ab1e37005dd9a559ae854a16304f9b839e062074106cc" @@ -146,6 +154,22 @@ ], "version": "==2.7" }, + "keyring": { + "hashes": [ + "sha256:6364bb8c233f28538df4928576f4e051229e0451651073ab20b315488da16a58", + "sha256:6e01954fd3e404820e1fade262ee661974051551ed08c899ffc5e88bb9df288e" + ], + "index": "pypi", + "version": "==13.2.1" + }, + "keyrings.alt": { + "hashes": [ + "sha256:6a00fa799baf1385cf9620bd01bcc815aa56e6970342a567bcfea0c4d21abe5f", + "sha256:b59c86b67b9027a86e841a49efc41025bcc3b1b0308629617b66b7011e52db5a" + ], + "index": "pypi", + "version": "==3.1" + }, "lxml": { "hashes": [ "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d", @@ -238,11 +262,11 @@ }, "youtube-dl": { "hashes": [ - "sha256:3300689ccc6cd22b8229e0c9cbb9f2cfcceaa3cef871a8e83c6a456623bb44e5", - "sha256:e4f81d38d2c7b8fa6fa069cd0f0096835dcaab91e74a4ca760625b76e0c614b4" + "sha256:08d91f0e4dd2d4608a677aa68896b272dcd243a966e161643242203c291b051e", + "sha256:cc380b9f2dee75370760fdc8dbfc7ff4eb0b611fa07ab86b80aafc2be5d08a4d" ], "index": "pypi", - "version": "==2018.7.10" + "version": "==2018.7.21" } }, "develop": { diff --git a/README.md b/README.md index 6847c9c..d500187 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ A Mastodon client that automatically plays your friends' music as they toot links to it. -## What's new in 2.0 +## What's new in 2.1 -If you've been using fediplay before, the all-new version 2.0 will be a little different! +If you've been using fediplay before, the all-new version 2.1 will be a little different! - You now specify the instance you want to stream from on the command line, instead of setting it in the environment. fediplay has been upgraded with the power of [Click](http://click.pocoo.org/) to give it a more modern command-line interface. -- We use [appdirs](https://github.com/ActiveState/appdirs) to store your credentials in your operating system's user config directory, and downloaded music files in your operating system's user cache directory. If you already have `.secret` files from an earlier version, we'll move and rename them automatically for you. +- We use [appdirs](https://pypi.org/project/appdirs/) to keep downloaded music files in your operating system's user cache directory. + +- We use [keyring](https://pypi.org/project/keyring/) to store your client credentials and access token, securely if your operating system supports it. If you already have `.secret` files from an earlier version, we'll migrate them automatically for you. Be sure to follow all the instructions, including re-running `pipenv install` to update the installed dependencies. diff --git a/fediplay/cli.py b/fediplay/cli.py index a35e4ab..a704aa2 100644 --- a/fediplay/cli.py +++ b/fediplay/cli.py @@ -1,69 +1,52 @@ '''Entry point for command-line interface.''' import os +path = os.path import sys import appdirs import click from mastodon import Mastodon +from fediplay.dirs import DIRS import fediplay.mastodon as mastodon +import fediplay.keyring as keyring -path = os.path - -dirs = appdirs.AppDirs('fediplay', 'zigg') - - -def build_usercred_filename(instance): - '''Generate a usercred filename from an instance name.''' - - return path.join(dirs.user_config_dir, instance + '.usercred.secret') - -def build_clientcred_filename(instance): - '''Generate a clientcred filename from an instance name.''' - - return path.join(dirs.user_config_dir, instance + '.clientcred.secret') def ensure_dirs(): '''Make sure the application directories exist.''' - if not path.exists(dirs.user_config_dir): - os.makedirs(dirs.user_config_dir) + if not path.exists(DIRS.user_config_dir): + os.makedirs(DIRS.user_config_dir) - if not path.exists(dirs.user_cache_dir): - os.makedirs(dirs.user_cache_dir) + if not path.exists(DIRS.user_cache_dir): + os.makedirs(DIRS.user_cache_dir) -def ensure_usercred(instance): - '''Ensure the usercred file exists.''' +def get_access_token(instance): + '''Ensure the user credential exists.''' - usercred = build_usercred_filename(instance) + keyring.migrate_access_token(instance) - if path.exists(usercred): - return usercred + if not keyring.has_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN): + click.echo('user credential for {} does not exist; try `fediplay login`'.format(instance)) + sys.exit(1) - if path.exists('usercred.secret'): - click.echo('==> Migrating usercred.secret to ' + usercred) - os.rename('usercred.secret', usercred) - return usercred + return keyring.get_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN) - click.echo(usercred + ' does not exist; try `fediplay login`') - sys.exit(1) +def get_client_credentials(instance): + '''Ensure the client credentials exist.''' -def ensure_clientcred(instance): - '''Ensure the clientcred file exists.''' + keyring.migrate_client_credentials(instance) - clientcred = build_clientcred_filename(instance) + if not (keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_ID) and + keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET)): + click.echo('client credentials for {} do not exist; try `fediplay register`'.format(instance)) + sys.exit(1) - if path.exists(clientcred): - return clientcred - - if path.exists('clientcred.secret'): - click.echo('==> Migrating clientcred.secret to ' + clientcred) - os.rename('clientcred.secret', clientcred) - return clientcred - - click.echo(clientcred + ' does not exist; try `fediplay register`') - sys.exit(1) + return ( + keyring.get_credential(instance, keyring.CREDENTIAL_CLIENT_ID), + keyring.get_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET) + ) @click.group() def cli(): @@ -76,43 +59,30 @@ def cli(): def register(instance): '''Register fediplay on your Mastodon instance.''' - clientcred = build_clientcred_filename(instance) - - if path.exists(clientcred): - click.echo(clientcred + ' already exists') - sys.exit(1) - - mastodon.register(instance, clientcred) + mastodon.register(instance) @cli.command() @click.argument('instance') def login(instance): '''Log in to your Mastodon instance.''' - clientcred = ensure_clientcred(instance) - - usercred = build_usercred_filename(instance) - if path.exists(usercred): - click.echo(usercred + ' already exists') - sys.exit(1) - - client = mastodon.build_client(instance, clientcred) + client_id, client_secret = get_client_credentials(instance) click.echo('Open this page in your browser and follow the instructions.') click.echo('Paste the code here.') click.echo('') - click.echo(mastodon.get_auth_request_url(client)) + click.echo(mastodon.get_auth_request_url(instance, client_id, client_secret)) click.echo('') grant_code = input('Code: ') - mastodon.login(client, grant_code, usercred) + mastodon.login(instance, client_id, client_secret, grant_code) @cli.command() @click.argument('instance') def stream(instance): '''Stream music from your timeline.''' - clientcred = ensure_clientcred(instance) - usercred = ensure_usercred(instance) + client_id, client_secret = get_client_credentials(instance) + access_token = get_access_token(instance) - mastodon.stream(instance, clientcred, usercred, cache_dir=dirs.user_cache_dir) + mastodon.stream(instance, client_id, client_secret, access_token, cache_dir=DIRS.user_cache_dir) diff --git a/fediplay/dirs.py b/fediplay/dirs.py new file mode 100644 index 0000000..23b352d --- /dev/null +++ b/fediplay/dirs.py @@ -0,0 +1,6 @@ +'''Application directories.''' + +from appdirs import AppDirs + + +DIRS = AppDirs('fediplay', 'zigg') diff --git a/fediplay/keyring.py b/fediplay/keyring.py new file mode 100644 index 0000000..7344253 --- /dev/null +++ b/fediplay/keyring.py @@ -0,0 +1,60 @@ +'''Secret storage.''' + +import os +path = os.path + +import appdirs +import click +from keyring import get_password, set_password + +from fediplay.dirs import DIRS + + +SERVICE_NAME = 'fediplay' +CREDENTIAL_CLIENT_ID = 'client_id' +CREDENTIAL_CLIENT_SECRET = 'client_secret' +CREDENTIAL_ACCESS_TOKEN = 'access_token' + +def build_username(instance, credential_kind): + return credential_kind + '@' + instance + +def set_credential(instance, credential_kind, credential): + set_password(SERVICE_NAME, build_username(instance, credential_kind), credential) + +def get_credential(instance, credential_kind): + return get_password(SERVICE_NAME, build_username(instance, credential_kind)) + +def has_credential(instance, credential_kind): + return get_credential(instance, credential_kind) is not None + +def migrate_client_credentials(instance): + def migrate_and_unlink(filename): + if path.exists(filename): + click.echo('==> Migrating client credentials to keyring from ' + filename) + + with open(filename, 'r', encoding='utf-8') as infile: + client_id = infile.readline().strip() + client_secret = infile.readline().strip() + + set_credential(instance, CREDENTIAL_CLIENT_ID, client_id) + set_credential(instance, CREDENTIAL_CLIENT_SECRET, client_secret) + + os.unlink(filename) + + migrate_and_unlink('clientcred.secret') + migrate_and_unlink(path.join(DIRS.user_config_dir, instance + '.clientcred.secret')) + +def migrate_access_token(instance): + def migrate_and_unlink(filename): + if path.exists(filename): + click.echo('==> Migrating access token to keyring from ' + filename) + + with open(filename, 'r', encoding='utf-8') as infile: + access_token = infile.readline().strip() + + set_credential(instance, CREDENTIAL_ACCESS_TOKEN, access_token) + + os.unlink(filename) + + migrate_and_unlink('usercred.secret') + migrate_and_unlink(path.join(DIRS.user_config_dir, instance + '.usercred.secret')) diff --git a/fediplay/mastodon.py b/fediplay/mastodon.py index 433bcc9..2347ec1 100644 --- a/fediplay/mastodon.py +++ b/fediplay/mastodon.py @@ -7,6 +7,7 @@ from lxml.etree import HTML # pylint: disable=no-name-in-module import mastodon from youtube_dl.utils import DownloadError +import fediplay.keyring as keyring from fediplay.queue import Queue Mastodon = mastodon.Mastodon @@ -35,34 +36,35 @@ class StreamListener(mastodon.StreamListener): except DownloadError: pass -def register(instance, clientcred): +def register(instance): '''Register fediplay to a Mastodon server and save the client credentials.''' - saved_umask = umask(0o77) - Mastodon.create_app('fediplay', scopes=['read'], api_base_url=api_base_url(instance), to_file=clientcred) - umask(saved_umask) + client_id, client_secret = Mastodon.create_app('fediplay', scopes=['read'], api_base_url=api_base_url(instance)) + keyring.set_credential(instance, keyring.CREDENTIAL_CLIENT_ID, client_id) + keyring.set_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET, client_secret) -def build_client(instance, clientcred, usercred=None): +def build_client(instance, client_id, client_secret, access_token=None): '''Builds a Mastodon client.''' - return Mastodon(client_id=clientcred, access_token=usercred, api_base_url=api_base_url(instance)) + return Mastodon(api_base_url=api_base_url(instance), + client_id=client_id, client_secret=client_secret, access_token=access_token) -def get_auth_request_url(client): +def get_auth_request_url(instance, client_id, client_secret): '''Gets an authorization request URL from a Mastodon instance.''' - return client.auth_request_url(scopes=['read']) + return build_client(instance, client_id, client_secret).auth_request_url(scopes=['read']) -def login(client, grant_code, usercred): +def login(instance, client_id, client_secret, grant_code): '''Log in to a Mastodon server and save the user credentials.''' - saved_umask = umask(0o77) - client.log_in(code=grant_code, scopes=['read'], to_file=usercred) - umask(saved_umask) + client = build_client(instance, client_id, client_secret) + access_token = client.log_in(code=grant_code, scopes=['read']) + keyring.set_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN, access_token) -def stream(instance, clientcred, usercred, cache_dir='.'): +def stream(instance, client_id, client_secret, access_token, cache_dir='.'): '''Stream statuses and add them to a queue.''' - client = build_client(instance, clientcred, usercred) + client = build_client(instance, client_id, client_secret, access_token) listener = StreamListener(Queue(cache_dir)) click.echo('==> Streaming from {}'.format(instance)) client.stream_user(listener)