From 145523e08a018d5fe4f6c6078c9ce211aedede89 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 5 Nov 2015 18:45:25 +0530 Subject: [PATCH] CLI to manage user accounts for the new server --- src/calibre/srv/opts.py | 1 + src/calibre/srv/standalone.py | 121 +++++++++++++++++++++++++++++++--- src/calibre/srv/users.py | 98 +++++++++++++++++++-------- 3 files changed, 186 insertions(+), 34 deletions(-) diff --git a/src/calibre/srv/opts.py b/src/calibre/srv/opts.py index b517c040d7..48c5107055 100644 --- a/src/calibre/srv/opts.py +++ b/src/calibre/srv/opts.py @@ -110,6 +110,7 @@ raw_options = ( ' will use "basic" if SSL is configured otherwise it will use "digest".', ) assert len(raw_options) % 4 == 0 +# TODO: Mark these strings for translation, once you finalize the option set options = [] diff --git a/src/calibre/srv/standalone.py b/src/calibre/srv/standalone.py index 3b2ea7c87b..9694108bdf 100644 --- a/src/calibre/srv/standalone.py +++ b/src/calibre/srv/standalone.py @@ -8,8 +8,8 @@ __copyright__ = '2015, Kovid Goyal ' import sys, os, signal -from calibre import as_unicode -from calibre.constants import plugins, iswindows +from calibre import as_unicode, prints +from calibre.constants import plugins, iswindows, preferred_encoding from calibre.srv.errors import InvalidCredentials from calibre.srv.loop import ServerLoop from calibre.srv.bonjour import BonJour @@ -74,9 +74,103 @@ class Server(object): from calibre.utils.rapydscript import compile_srv compile_srv() +# Manage users CLI {{{ +def manage_users(path=None): + from calibre.srv.users import UserManager + m = UserManager(path) + enc = getattr(sys.stdin, 'encoding', preferred_encoding) or preferred_encoding + + def choice(question, choices, default=None, banner=''): + prints(banner) + for i, choice in enumerate(choices): + prints('%d)' % (i+1), choice) + print() + while True: + prompt = question + ' [1-%d]: ' % len(choices) + if default is not None: + prompt = question + ' [1-%d %s: %d]' % (len(choices), _('default'), default+1) + reply = raw_input(prompt) + if not reply and default is not None: + reply = str(default + 1) + if not reply: + raise SystemExit(0) + reply = reply.strip() + try: + num = int(reply) - 1 + if not (0 <= num < len(choices)): + raise Exception('bad num') + return num + except Exception: + prints(_('%s is not a valid choice, try again') % reply) + + def get_valid(prompt, invalidq=lambda x: None): + while True: + ans = raw_input(prompt + ': ').strip().decode(enc) + fail_message = invalidq(ans) + if fail_message is None: + return ans + prints(fail_message) + + def get_valid_user(): + prints(_('Existing user names:')) + users = sorted(m.all_user_names) + if not users: + raise SystemExit(_('There are no users, you must first add an user')) + prints(', '.join(users)) + + def validate(username): + if not m.has_user(username): + return _('The username %s does not exist') % username + return get_valid(_('Enter the username'), validate) + + def get_pass(username): + while True: + from getpass import getpass + one = getpass(_('Enter the new password for %s: ') % username).decode(enc) + if not one: + prints(_('Empty passwords are not allowed')) + continue + two = getpass(_('Re-enter the new password for %s, to verify: ') % username).decode(enc) + if one != two: + prints(_('Passwords do not match')) + continue + msg = m.validate_password(one) + if msg is None: + return one + prints(msg) + + def add_user(): + username = get_valid(_('Enter the username'), m.validate_username) + pw = get_pass(username) + m.add_user(username, pw) + prints(_('User %s added successfully!') % username) + + def remove_user(): + un = get_valid_user() + if raw_input((_('Are you sure you want to remove the user %s?') % un) + ' [y/n]: ').decode(enc) != 'y': + raise SystemExit(0) + m.remove_user(un) + prints(_('User %s successfully removed!') % un) + + def edit_user(): + username = get_valid_user() + pw = get_pass(username) + m.change_password(username, pw) + prints(_('Password for %s successfully changed!') % username) + + def show_password(): + username = get_valid_user() + pw = m.get(username) + prints(_('Password for {0} is: {1}').format(username, pw)) + + {0:add_user, 1:edit_user, 2:remove_user, 3:show_password}[choice(_('What do you want to do?'), [ + _('Add a new user'), _('Edit an existing user'), _('Remove a user'), _('Show the password for a user')])]() + + +# }}} def create_option_parser(): - parser = opts_to_parser('%prog '+ _( + parser=opts_to_parser('%prog '+ _( '''[options] [path to library folder ...] Start the calibre content server. The calibre content server @@ -98,19 +192,30 @@ program will be used. help=_('Automatically reload server when source code changes. Useful' ' for development. You should also specify a small value for the' ' shutdown timeout.')) + parser.add_option( + '--manage-users', default=False, action='store_true', + help=_('Manage the database of users allowed to connect to this server.' + ' See also the %s option.') % '--userdb') return parser def main(args=sys.argv): - opts, args = create_option_parser().parse_args(args) - libraries = args[1:] + opts, args=create_option_parser().parse_args(args) + if opts.manage_users: + try: + manage_users(opts.userdb) + except (KeyboardInterrupt, EOFError): + raise SystemExit(_('Interrupted by user')) + raise SystemExit(0) + + libraries=args[1:] for lib in libraries: if not lib or not LibraryDatabase.exists_at(lib): raise SystemExit(_('There is no calibre library at: %s') % lib) if not libraries: if not prefs['library_path']: raise SystemExit(_('You must specify at least one calibre library')) - libraries = [prefs['library_path']] + libraries=[prefs['library_path']] if opts.auto_reload: if opts.daemonize: @@ -121,9 +226,9 @@ def main(args=sys.argv): return auto_reload(default_log) except NoAutoReload as e: raise SystemExit(e.message) - opts.auto_reload_port = int(os.environ.get('CALIBRE_AUTORELOAD_PORT', 0)) + opts.auto_reload_port=int(os.environ.get('CALIBRE_AUTORELOAD_PORT', 0)) try: - server = Server(libraries, opts) + server=Server(libraries, opts) except InvalidCredentials as e: raise SystemExit(e.message) if opts.daemonize: diff --git a/src/calibre/srv/users.py b/src/calibre/srv/users.py index f0ba681df2..faf1efe3c4 100644 --- a/src/calibre/srv/users.py +++ b/src/calibre/srv/users.py @@ -4,8 +4,8 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) -import os, json -from threading import Lock +import os, json, re +from threading import RLock import apsw @@ -15,34 +15,37 @@ from calibre.utils.config import to_json, from_json class UserManager(object): - lock = Lock() + lock = RLock() @property def conn(self): - if self._conn is None: - self._conn = apsw.Connection(self.path) - with self._conn: - c = self._conn.cursor() - uv = next(c.execute('PRAGMA user_version'))[0] - if uv == 0: - # We have to store the unhashed password, since the digest - # auth scheme requires it. - # timestamp stores the ISO 8601 creation timestamp in UTC. - c.execute(''' - CREATE TABLE users ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - pw TEXT NOT NULL, - timestamp TEXT DEFAULT CURRENT_TIMESTAMP, - session_data TEXT NOT NULL DEFAULT "{}", - restriction TEXT NOT NULL DEFAULT "", - misc_data TEXT NOT NULL DEFAULT "{}", - UNIQUE(name) - ); + with self.lock: + if self._conn is None: + self._conn = apsw.Connection(self.path) + with self._conn: + c = self._conn.cursor() + uv = next(c.execute('PRAGMA user_version'))[0] + if uv == 0: + # We have to store the unhashed password, since the digest + # auth scheme requires it. + # timestamp stores the ISO 8601 creation timestamp in UTC. + c.execute(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + pw TEXT NOT NULL, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + session_data TEXT NOT NULL DEFAULT "{}", + restriction TEXT NOT NULL DEFAULT "", + readonly TEXT NOT NULL DEFAULT "n", + misc_data TEXT NOT NULL DEFAULT "{}", + UNIQUE(name) + ); - PRAGMA user_version=1; - ''') - c.close() + PRAGMA user_version=1; + ''') + c.close() + return self._conn def __init__(self, path=None): self.path = os.path.join(config_dir, 'server-users.sqlite') if path is None else path @@ -73,3 +76,46 @@ class UserManager(object): for pw, in self.conn.cursor().execute( 'SELECT pw FROM users WHERE name=?', (username,)): return pw + + def has_user(self, username): + return self.get(username) is not None + + def validate_username(self, username): + if self.has_user(username): + return _('The username %s already exists') % username + if re.sub(r'[a-zA-Z0-9 ]', '', username): + return _('For maximum compatibility you should use only the letters A-Z, the numbers 0-9 and spaces in the username') + + def validate_password(self, pw): + try: + pw = pw.encode('ascii', 'strict') + except ValueError: + return _('The password must contain only ASCII (English) characters and symbols') + + def add_user(self, username, pw, restriction='', readonly=False): + with self.lock: + msg = self.validate_username(username) or self.validate_password(pw) + if msg is not None: + raise ValueError(msg) + self.conn.cursor().execute( + 'INSERT INTO users (name, pw, restriction, readonly) VALUES (?, ?, ?, ?)', + (username, pw, restriction, ('y' if readonly else 'n'))) + + def remove_user(self, username): + with self.lock: + self.conn.cursor().execute('DELETE FROM users WHERE name=?', (username,)) + return self.conn.changes() > 0 + + @property + def all_user_names(self): + with self.lock: + return {x for x, in self.conn.cursor().execute( + 'SELECT name FROM users')} + + def change_password(self, username, pw): + with self.lock: + msg = self.validate_password(pw) + if msg is not None: + raise ValueError(msg) + self.conn.cursor().execute( + 'UPDATE users SET pw=? WHERE name=?', (pw, username))