Merge pull request #20 from zigg/keyring

2.1
This commit is contained in:
Matt Behrens 2018-07-22 20:39:13 -04:00 committed by GitHub
commit 1f1829551f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 149 additions and 83 deletions

View file

@ -12,6 +12,8 @@ python-dotenv = "*"
click = "*" click = "*"
"e1839a8" = {path = ".", editable = true} "e1839a8" = {path = ".", editable = true}
appdirs = "*" appdirs = "*"
keyring = "*"
"keyrings.alt" = "*"
[dev-packages] [dev-packages]
pytest = "*" pytest = "*"

32
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "add9770cb1b3cd200c03d312f2a977e024c75df453ca2f33094265959e1e7376" "sha256": "823dc15b2e595d044b28df0bb1d28da5da1f2235baa200cf8d36c3eeb988a86d"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -133,6 +133,14 @@
"editable": true, "editable": true,
"path": "." "path": "."
}, },
"entrypoints": {
"hashes": [
"sha256:10ad569bb245e7e2ba425285b9fa3e8178a0dc92fc53b1e1c553805e15a8825b",
"sha256:d2d587dde06f99545fb13a383d2cd336a8ff1f359c5839ce3a64c917d10c029f"
],
"markers": "python_version >= '2.7'",
"version": "==0.2.3"
},
"http-ece": { "http-ece": {
"hashes": [ "hashes": [
"sha256:2f31a0640c31a0c2934ab1e37005dd9a559ae854a16304f9b839e062074106cc" "sha256:2f31a0640c31a0c2934ab1e37005dd9a559ae854a16304f9b839e062074106cc"
@ -146,6 +154,22 @@
], ],
"version": "==2.7" "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": { "lxml": {
"hashes": [ "hashes": [
"sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d", "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d",
@ -238,11 +262,11 @@
}, },
"youtube-dl": { "youtube-dl": {
"hashes": [ "hashes": [
"sha256:3300689ccc6cd22b8229e0c9cbb9f2cfcceaa3cef871a8e83c6a456623bb44e5", "sha256:08d91f0e4dd2d4608a677aa68896b272dcd243a966e161643242203c291b051e",
"sha256:e4f81d38d2c7b8fa6fa069cd0f0096835dcaab91e74a4ca760625b76e0c614b4" "sha256:cc380b9f2dee75370760fdc8dbfc7ff4eb0b611fa07ab86b80aafc2be5d08a4d"
], ],
"index": "pypi", "index": "pypi",
"version": "==2018.7.10" "version": "==2018.7.21"
} }
}, },
"develop": { "develop": {

View file

@ -2,13 +2,15 @@
A Mastodon client that automatically plays your friends' music as they toot links to it. 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. - 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. Be sure to follow all the instructions, including re-running `pipenv install` to update the installed dependencies.

View file

@ -1,69 +1,52 @@
'''Entry point for command-line interface.''' '''Entry point for command-line interface.'''
import os import os
path = os.path
import sys import sys
import appdirs import appdirs
import click import click
from mastodon import Mastodon from mastodon import Mastodon
from fediplay.dirs import DIRS
import fediplay.mastodon as mastodon 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(): def ensure_dirs():
'''Make sure the application directories exist.''' '''Make sure the application directories exist.'''
if not path.exists(dirs.user_config_dir): if not path.exists(DIRS.user_config_dir):
os.makedirs(dirs.user_config_dir) os.makedirs(DIRS.user_config_dir)
if not path.exists(dirs.user_cache_dir): if not path.exists(DIRS.user_cache_dir):
os.makedirs(dirs.user_cache_dir) os.makedirs(DIRS.user_cache_dir)
def ensure_usercred(instance): def get_access_token(instance):
'''Ensure the usercred file exists.''' '''Ensure the user credential exists.'''
usercred = build_usercred_filename(instance) keyring.migrate_access_token(instance)
if path.exists(usercred): if not keyring.has_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN):
return usercred click.echo('user credential for {} does not exist; try `fediplay login`'.format(instance))
sys.exit(1)
if path.exists('usercred.secret'): return keyring.get_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN)
click.echo('==> Migrating usercred.secret to ' + usercred)
os.rename('usercred.secret', usercred)
return usercred
click.echo(usercred + ' does not exist; try `fediplay login`') def get_client_credentials(instance):
sys.exit(1) '''Ensure the client credentials exist.'''
def ensure_clientcred(instance): keyring.migrate_client_credentials(instance)
'''Ensure the clientcred file exists.'''
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 (
return clientcred keyring.get_credential(instance, keyring.CREDENTIAL_CLIENT_ID),
keyring.get_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET)
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)
@click.group() @click.group()
def cli(): def cli():
@ -76,43 +59,30 @@ def cli():
def register(instance): def register(instance):
'''Register fediplay on your Mastodon instance.''' '''Register fediplay on your Mastodon instance.'''
clientcred = build_clientcred_filename(instance) mastodon.register(instance)
if path.exists(clientcred):
click.echo(clientcred + ' already exists')
sys.exit(1)
mastodon.register(instance, clientcred)
@cli.command() @cli.command()
@click.argument('instance') @click.argument('instance')
def login(instance): def login(instance):
'''Log in to your Mastodon instance.''' '''Log in to your Mastodon instance.'''
clientcred = ensure_clientcred(instance) client_id, client_secret = get_client_credentials(instance)
usercred = build_usercred_filename(instance)
if path.exists(usercred):
click.echo(usercred + ' already exists')
sys.exit(1)
client = mastodon.build_client(instance, clientcred)
click.echo('Open this page in your browser and follow the instructions.') click.echo('Open this page in your browser and follow the instructions.')
click.echo('Paste the code here.') click.echo('Paste the code here.')
click.echo('') click.echo('')
click.echo(mastodon.get_auth_request_url(client)) click.echo(mastodon.get_auth_request_url(instance, client_id, client_secret))
click.echo('') click.echo('')
grant_code = input('Code: ') grant_code = input('Code: ')
mastodon.login(client, grant_code, usercred) mastodon.login(instance, client_id, client_secret, grant_code)
@cli.command() @cli.command()
@click.argument('instance') @click.argument('instance')
def stream(instance): def stream(instance):
'''Stream music from your timeline.''' '''Stream music from your timeline.'''
clientcred = ensure_clientcred(instance) client_id, client_secret = get_client_credentials(instance)
usercred = ensure_usercred(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)

6
fediplay/dirs.py Normal file
View file

@ -0,0 +1,6 @@
'''Application directories.'''
from appdirs import AppDirs
DIRS = AppDirs('fediplay', 'zigg')

60
fediplay/keyring.py Normal file
View file

@ -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'))

View file

@ -7,6 +7,7 @@ from lxml.etree import HTML # pylint: disable=no-name-in-module
import mastodon import mastodon
from youtube_dl.utils import DownloadError from youtube_dl.utils import DownloadError
import fediplay.keyring as keyring
from fediplay.queue import Queue from fediplay.queue import Queue
Mastodon = mastodon.Mastodon Mastodon = mastodon.Mastodon
@ -35,34 +36,35 @@ class StreamListener(mastodon.StreamListener):
except DownloadError: except DownloadError:
pass pass
def register(instance, clientcred): def register(instance):
'''Register fediplay to a Mastodon server and save the client credentials.''' '''Register fediplay to a Mastodon server and save the client credentials.'''
saved_umask = umask(0o77) client_id, client_secret = Mastodon.create_app('fediplay', scopes=['read'], api_base_url=api_base_url(instance))
Mastodon.create_app('fediplay', scopes=['read'], api_base_url=api_base_url(instance), to_file=clientcred) keyring.set_credential(instance, keyring.CREDENTIAL_CLIENT_ID, client_id)
umask(saved_umask) 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.''' '''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.''' '''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.''' '''Log in to a Mastodon server and save the user credentials.'''
saved_umask = umask(0o77) client = build_client(instance, client_id, client_secret)
client.log_in(code=grant_code, scopes=['read'], to_file=usercred) access_token = client.log_in(code=grant_code, scopes=['read'])
umask(saved_umask) 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.''' '''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)) listener = StreamListener(Queue(cache_dir))
click.echo('==> Streaming from {}'.format(instance)) click.echo('==> Streaming from {}'.format(instance))
client.stream_user(listener) client.stream_user(listener)