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 = "*"
"e1839a8" = {path = ".", editable = true}
appdirs = "*"
keyring = "*"
"keyrings.alt" = "*"
[dev-packages]
pytest = "*"

32
Pipfile.lock generated
View file

@ -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": {

View file

@ -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.

View file

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

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