Keep UUID out of config file and call it "install ID"

We referred to this locally generated UUID as "API token" which was wrong,
because it doesn't have any special API powers, and is only used to link local
machine with uploaded recordings, so they can later be associated with
asciinema.org account.

It's more of device token or installation ID in nature, so let's call it
"install ID" for short, and keep it at ~/.config/asciinema/install-id.

Keeping it in automatically created config file also turned out to be not the
best idea - the config was mixing user preferences with local, device specific
state, preventing easy publishing of the config (for example in a public
dotfiles repository).
This commit is contained in:
Marcin Kulik
2017-12-04 00:25:50 +01:00
parent b7231c2872
commit 331dcf497f
6 changed files with 223 additions and 157 deletions

103
README.md
View File

@@ -253,19 +253,29 @@ publishing it on asciinema.org.
### `auth`
__Manage recordings on asciinema.org account.__
__Link your install ID with your asciinema.org user account.__
If you want to manage your recordings on asciinema.org (set title/description,
delete etc) you need to authenticate. This command displays the URL you should
open in your web browser to do that.
If you want to manage your recordings (change title/theme, delete) at
asciinema.org you need to link your "install ID" with asciinema.org user
account.
On every machine you run asciinema recorder, you get a new, unique API token. If
you're already logged in on asciinema.org website and you run `asciinema auth`
from a new computer then this new device will be linked to your account.
This command displays the URL to open in a web browser to do that. You may be
asked to log in first.
You can synchronize your config file (which keeps the API token) across the
machines so all of them use the same token, but that's not necessary. You can
assign new tokens to your account from as many machines as you want.
Install ID is a random ID ([UUID
v4](https://en.wikipedia.org/wiki/Universally_unique_identifier)) generated
locally when you run asciinema for the first time, and saved at
`$HOME/.config/asciinema/install-id`. It's purpose is to connect local machine
with uploaded recordings, so they can later be associated with asciinema.org
account. This way we decouple uploading from account creation, allowing them to
happen in any order.
> A new install ID is generated on each machine and system user account you use
> asciinema on, so in order to keep all recordings under a single asciinema.org
> account you need to run `asciinema auth` on all of those machines.
> asciinema versions prior to 2.0 confusingly referred to install ID as "API
> token".
## Hosting the recordings on the web
@@ -287,87 +297,64 @@ If you prefer to host the recordings yourself, you can do so by either:
## Configuration file
asciinema uses a config file to keep API token and user settings. In most cases
the location of this file is `$HOME/.config/asciinema/config`.
You can configure asciinema by creating config file at
`$HOME/.config/asciinema/config`.
*NOTE: When you first run asciinema, local API token is generated (UUID) and
saved in the file (unless the file already exists or you have set
`ASCIINEMA_API_TOKEN` environment variable).*
The auto-generated, minimal config file looks like this:
```ini
[api]
token = <your-api-token-here>
```
There are several options you can set in this file. Here's a config with all
available options set:
Configuration is split into sections (`[api]`, `[record]`, `[play]`). Here's a
list of all available options for each section:
```ini
[api]
; API server URL, default: https://asciinema.org
; If you run your own instance of asciinema-server then set its address here
; It can also be overriden by setting ASCIINEMA_API_URL environment variable
url = https://asciinema.example.com
; API token, autogenerated when uploading a recording for the first time
token = <your-api-token-here>
[record]
; command to record, default: $SHELL
; Command to record, default: $SHELL
command = /bin/bash -l
; enable stdin (keyboard) recording, default: no
; Enable stdin (keyboard) recording, default: no
stdin = yes
; list of environment variables to capture, default: SHELL,TERM
; List of environment variables to capture, default: SHELL,TERM
env = SHELL,TERM,USER
; limit recorded terminal inactivity to max n seconds, default: off
; Limit recorded terminal inactivity to max n seconds, default: off
idle_time_limit = 2
; answer "yes" to all interactive prompts, default: no
; Answer "yes" to all interactive prompts, default: no
yes = true
; be quiet, suppress all notices/warnings, default: no
; Be quiet, suppress all notices/warnings, default: no
quiet = true
[play]
; playback speed (can be fractional), default: 1
; Playback speed (can be fractional), default: 1
speed = 2
; limit replayed terminal inactivity to max n seconds, default: off
; Limit replayed terminal inactivity to max n seconds, default: off
idle_time_limit = 1
```
The options in `[api]` section are related to API location and authentication.
To tell asciinema recorder to use your own asciinema server instance rather than
the default one (asciinema.org), you can set `url` option. API URL can also be
passed via `ASCIINEMA_API_URL` environment variable, and API token via
`ASCIINEMA_API_TOKEN` environment variable.
A very minimal config file could look like that:
The options in `[record]` and `[play]` sections have the same meaning as the
options you pass to `asciinema rec`/`asciinema play` command. If you happen to
often use either `-c`, `-i` or `-y` with these commands then consider saving it
as a default in the config file.
```ini
[record]
idle_time_limit = 2
```
> If you want to publish your asciinema config file (in public dotfiles
> repository) you __should__ remove `token = ...` line from the file and use
> `ASCIINEMA_API_TOKEN` environment variable instead.
Config file location can be changed by setting `$ASCIINEMA_CONFIG_HOME`
environment variable, which should point to a directory.
### Configuration file locations
If `$XDG_CONFIG_HOME` is set on Linux then asciinema uses
`$XDG_CONFIG_HOME/asciinema/config` instead of `$HOME/.config/asciinema/config`.
In fact, the following locations are checked for the presence of the config
file (in the given order):
* `$ASCIINEMA_CONFIG_HOME/config` - if you have set `$ASCIINEMA_CONFIG_HOME`
* `$XDG_CONFIG_HOME/asciinema/config` - on Linux, `$XDG_CONFIG_HOME` usually points to `$HOME/.config/`
* `$HOME/.config/asciinema/config` - in most cases it's here
* `$HOME/.asciinema/config` - created by asciinema versions prior to 1.1
The first one found is used.
> asciinema versions prior to 1.1 used `$HOME/.asciinema/config`. If you have it
> there you should `mv $HOME/.asciinema $HOME/.config/asciinema`.
## Contributing

View File

@@ -22,7 +22,7 @@ def positive_float(value):
def rec_command(args, config):
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
return RecordCommand(api, args)
@@ -35,12 +35,12 @@ def cat_command(args, config):
def upload_command(args, config):
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
return UploadCommand(api, args.filename)
def auth_command(args, config):
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
return AuthCommand(api)
@@ -54,7 +54,11 @@ def main():
print("asciinema needs a UTF-8 native locale to run. Check the output of `locale` command.")
sys.exit(1)
cfg = config.load()
try:
cfg = config.load()
except config.ConfigError as e:
sys.stderr.write(str(e) + '\n')
sys.exit(1)
# create the top-level parser
parser = argparse.ArgumentParser(

View File

@@ -13,17 +13,17 @@ class APIError(Exception):
class Api:
def __init__(self, url, user, token, http_adapter=None):
def __init__(self, url, user, install_id, http_adapter=None):
self.url = url
self.user = user
self.token = token
self.install_id = install_id
self.http_adapter = http_adapter if http_adapter is not None else URLLibHttpAdapter()
def hostname(self):
return urlparse(self.url).hostname
def auth_url(self):
return "{}/connect/{}".format(self.url, self.token)
return "{}/connect/{}".format(self.url, self.install_id)
def upload_url(self):
return "{}/api/asciicasts".format(self.url)
@@ -36,7 +36,7 @@ class Api:
files={"asciicast": ("ascii.cast", f)},
headers=self._headers(),
username=self.user,
password=self.token
password=self.install_id
)
except HTTPConnectionError as e:
raise APIError(str(e))
@@ -61,7 +61,7 @@ class Api:
def _handle_error(self, status, body):
errors = {
400: "Invalid request: %s" % body,
401: "Invalid or revoked recorder token",
401: "Invalid or revoked install ID",
404: "API endpoint not found. This asciinema version may no longer be supported. Please upgrade to the latest version.",
413: "Sorry, your asciicast is too big.",
422: "Invalid asciicast: %s" % body,

View File

@@ -8,6 +8,10 @@ class AuthCommand(Command):
self.api = api
def execute(self):
self.print('Open the following URL in a browser to register your API token\n'
'and assign any recorded asciicasts to your %s profile.\n\n'
'%s\n' % (self.api.hostname(), self.api.auth_url()))
self.print('Open the following URL in a web browser to link your '
'install ID with your %s user account:\n\n'
'%s\n\n'
'This will associate all recordings uploaded from this machine '
'(past and future ones) to your account, '
'and allow you to manage them (change title/theme, delete) at %s.'
% (self.api.hostname(), self.api.auth_url(), self.api.hostname()))

View File

@@ -15,10 +15,68 @@ DEFAULT_RECORD_ENV = 'SHELL,TERM'
class Config:
def __init__(self, config, env=None):
self.config = config
def __init__(self, config_home, env=None):
self.config_home = config_home
self.config_file_path = path.join(config_home, "config")
self.install_id_path = path.join(self.config_home, 'install-id')
self.config = configparser.ConfigParser()
self.config.read(self.config_file_path)
self.env = env if env is not None else os.environ
def upgrade(self):
try:
self.install_id
except ConfigError:
id = self.__api_token() or self.__user_token() or self.__gen_install_id()
self.__save_install_id(id)
items = {name: dict(section) for (name, section) in self.config.items()}
if items == {'DEFAULT': {}, 'api': {'token': id}} or items == {'DEFAULT': {}, 'user': {'token': id}}:
os.remove(self.config_file_path)
if self.env.get('ASCIINEMA_API_TOKEN'):
raise ConfigError('ASCIINEMA_API_TOKEN variable is no longer supported, please use ASCIINEMA_INSTALL_ID instead')
def __read_install_id(self):
p = self.install_id_path
if path.isfile(p):
with open(p, 'r') as f:
return f.read().strip()
def __gen_install_id(self):
return str(uuid.uuid4())
def __save_install_id(self, id):
self.__create_config_home()
with open(self.install_id_path, 'w') as f:
f.write(id)
def __create_config_home(self):
if not path.exists(self.config_home):
os.makedirs(self.config_home)
def __api_token(self):
try:
return self.config.get('api', 'token')
except (configparser.NoOptionError, configparser.NoSectionError):
pass
def __user_token(self):
try:
return self.config.get('user', 'token')
except (configparser.NoOptionError, configparser.NoSectionError):
pass
@property
def install_id(self):
id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id()
if id:
return id
else:
raise ConfigError('no install ID found')
@property
def api_url(self):
return self.env.get(
@@ -26,16 +84,6 @@ class Config:
self.config.get('api', 'url', fallback=DEFAULT_API_URL)
)
@property
def api_token(self):
try:
return self.env.get('ASCIINEMA_API_TOKEN') or self.config.get('api', 'token')
except (configparser.NoOptionError, configparser.NoSectionError):
try:
return self.config.get('user', 'token')
except (configparser.NoOptionError, configparser.NoSectionError):
raise ConfigError('no API token found in config file, and ASCIINEMA_API_TOKEN is unset')
@property
def record_stdin(self):
return self.config.getboolean('record', 'stdin', fallback=False)
@@ -71,45 +119,29 @@ class Config:
return self.config.getfloat('play', 'speed', fallback=1.0)
def load_file(paths):
config = configparser.ConfigParser()
read_paths = config.read(paths)
def get_config_home(env=os.environ):
env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
env_xdg_config_home = env.get("XDG_CONFIG_HOME")
env_home = env.get("HOME")
if read_paths:
return config
config_home = None
if env_asciinema_config_home:
config_home = env_asciinema_config_home
elif env_xdg_config_home:
config_home = path.join(env_xdg_config_home, "asciinema")
elif env_home:
if path.isfile(path.join(env_home, ".config", "asciinema", "config")):
config_home = path.join(env_home, ".config", "asciinema")
else:
config_home = path.join(env_home, ".asciinema") # location for versions < 1.1
else:
raise Exception("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME")
def create_file(filename):
config = configparser.ConfigParser()
config['api'] = {}
config['api']['token'] = str(uuid.uuid4())
if not path.exists(path.dirname(filename)):
os.makedirs(path.dirname(filename))
with open(filename, 'w') as f:
config.write(f)
return config
return config_home
def load(env=os.environ):
paths = []
asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
xdg_config_home = env.get("XDG_CONFIG_HOME")
home = env.get("HOME")
if asciinema_config_home:
paths.append(path.join(asciinema_config_home, "config"))
elif xdg_config_home:
paths.append(path.join(xdg_config_home, "asciinema", "config"))
elif home:
paths.append(path.join(home, ".asciinema", "config"))
paths.append(path.join(home, ".config", "asciinema", "config"))
else:
raise Exception("need $ASCIINEMA_CONFIG_HOME or $XDG_CONFIG_HOME or $HOME")
config = load_file(paths) or create_file(paths[-1])
return Config(config, env)
config = Config(get_config_home(env), env)
config.upgrade()
return config

View File

@@ -1,33 +1,97 @@
from nose.tools import assert_equal, assert_raises
import os
import os.path as path
import tempfile
import re
import asciinema.config as cfg
def create_config(content='', env={}):
def create_config(content=None, env={}):
dir = tempfile.mkdtemp()
path = dir + '/config'
with open(path, 'w') as f:
f.write(content)
if content:
path = dir + '/config'
with open(path, 'w') as f:
f.write(content)
return cfg.Config(cfg.load_file([path]), env)
return cfg.Config(dir, env)
def test_load_config():
with tempfile.TemporaryDirectory() as dir:
config = cfg.load({'ASCIINEMA_CONFIG_HOME': dir + '/foo/bar'})
assert re.match('^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}', config.api_token)
def read_install_id(install_id_path):
with open(install_id_path, 'r') as f:
return f.read().strip()
with open(dir + '/config', 'w') as f:
token = 'foo-bar-baz-qux-quux'
f.write("[api]\ntoken = %s" % token)
config = cfg.load({'ASCIINEMA_CONFIG_HOME': dir})
assert_equal(token, config.api_token)
def test_upgrade_no_config_file():
config = create_config()
config.upgrade()
install_id = read_install_id(config.install_id_path)
assert re.match('^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}', install_id)
assert_equal(install_id, config.install_id)
assert not path.exists(config.config_file_path)
# it must not change after another upgrade
config.upgrade()
assert_equal(read_install_id(config.install_id_path), install_id)
def test_upgrade_config_file_with_api_token():
config = create_config("[api]\ntoken = foo-bar-baz")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert not path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
def test_upgrade_config_file_with_api_token_and_more():
config = create_config("[api]\ntoken = foo-bar-baz\nurl = http://example.com")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert_equal(config.api_url, 'http://example.com')
assert path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
def test_upgrade_config_file_with_user_token():
config = create_config("[user]\ntoken = foo-bar-baz")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert not path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
def test_upgrade_config_file_with_user_token_and_more():
config = create_config("[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com")
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
assert_equal(config.install_id, 'foo-bar-baz')
assert_equal(config.api_url, 'http://example.com')
assert path.exists(config.config_file_path)
config.upgrade()
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
def test_default_api_url():
@@ -81,31 +145,6 @@ def test_api_url_when_override_set():
assert_equal('http://the/url2', config.api_url)
def test_api_token():
token = 'foo-bar-baz'
config = create_config("[api]\ntoken = %s" % token)
assert re.match(token, config.api_token)
def test_api_token_when_no_api_token_set():
config = create_config('')
with assert_raises(Exception):
config.api_token
def test_api_token_when_user_token_set():
token = 'foo-bar-baz'
config = create_config("[user]\ntoken = %s" % token)
assert re.match(token, config.api_token)
def test_api_token_when_api_token_set_and_user_token_set():
user_token = 'foo'
api_token = 'bar'
config = create_config("[user]\ntoken = %s\n[api]\ntoken = %s" % (user_token, api_token))
assert re.match(api_token, config.api_token)
def test_record_command():
command = 'bash -l'
config = create_config("[record]\ncommand = %s" % command)