diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0e1910d..6621f14 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Appendix A. Changelog ===================== +v1.2.0 +------ + +* Added support for a Limited mode, which does not require Redis and rq +* Nikola v7.4.0 compatibility + v1.1.0 ------ diff --git a/coil/data/templates/jinja/coil_account_single.tmpl b/coil/data/templates/jinja/coil_account_single.tmpl new file mode 100644 index 0000000..bc3b14b --- /dev/null +++ b/coil/data/templates/jinja/coil_account_single.tmpl @@ -0,0 +1,56 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'base.tmpl' %} +{% block content %} + + +{% if alert %} + +{% endif %} + +
+
+

Profile

+
+
+ +

In order to change your details, please contact an + administrator.

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+

Password change

+
+
+

In order to change your password, please contact an + administrator and provide them with a hash of your + desired password. Generate a secure hash below.

+
+ +
+
+
+ +
+
+
+
+ {{ form.csrf_token }} +
+ +
+
+ +{% endblock %} diff --git a/coil/data/templates/jinja/coil_pwdhash.tmpl b/coil/data/templates/jinja/coil_pwdhash.tmpl new file mode 100644 index 0000000..5c3b8c3 --- /dev/null +++ b/coil/data/templates/jinja/coil_pwdhash.tmpl @@ -0,0 +1,39 @@ +{# -*- coding: utf-8 -*- #} +{% extends 'base.tmpl' %} +{% block content %} + + +
+
+
+

Password change

+
+ {% if status %} +
+

Please provide the following hash to your + administrator:

+

{{ pwdhash }}

+
+ {% else %} +
+

The passwords did not match. Please try again.

+
+ +
+
+
+ +
+
+
+
+ {{ form.csrf_token }} +
+ +
+ {% endif %} +
+ +{% endblock %} diff --git a/coil/data/templates/jinja/coil_rebuild_single.tmpl b/coil/data/templates/jinja/coil_rebuild_single.tmpl index 5cd6692..ac0dbfc 100644 --- a/coil/data/templates/jinja/coil_rebuild_single.tmpl +++ b/coil/data/templates/jinja/coil_rebuild_single.tmpl @@ -25,13 +25,11 @@ $(document).ready(function() { fs = $('.build-status-icon'); fsc = $('.build-status-caption'); - if ({{ bstatus }} == 1) { + if ({{ status }} == 1) { fs.removeClass('fa-cog'); fs.addClass('fa-check'); fsc.addClass('text-success'); - clearInterval(intID); } else { - pb.addClass('progress-bar-danger'); fs.removeClass('fa-cog'); fs.addClass('fa-times'); fsc.addClass('text-danger'); diff --git a/coil/data/templates/mako/coil_account_single.tmpl b/coil/data/templates/mako/coil_account_single.tmpl new file mode 100644 index 0000000..ad09c37 --- /dev/null +++ b/coil/data/templates/mako/coil_account_single.tmpl @@ -0,0 +1,56 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> +<%block name="content"> + + +% if alert: + +% endif + +
+
+

Profile

+
+
+ +

In order to change your details, please contact an + administrator.

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+

Password change

+
+
+

In order to change your password, please contact an + administrator and provide them with a hash of your + desired password. Generate a secure hash below.

+
+ +
+
+
+ +
+
+
+
+ ${form.csrf_token} +
+ +
+
+ + diff --git a/coil/data/templates/mako/coil_pwdhash.tmpl b/coil/data/templates/mako/coil_pwdhash.tmpl new file mode 100644 index 0000000..a3a6203 --- /dev/null +++ b/coil/data/templates/mako/coil_pwdhash.tmpl @@ -0,0 +1,39 @@ +## -*- coding: utf-8 -*- +<%inherit file="base.tmpl"/> +<%block name="content"> + + +
+
+
+

Password change

+
+ % if status: +
+

Please provide the following hash to your + administrator:

+

${pwdhash}

+
+ % else: +
+

The passwords did not match. Please try again.

+
+ +
+
+
+ +
+
+
+
+ ${form.csrf_token} +
+ +
+ % endif +
+ + diff --git a/coil/data/templates/mako/coil_rebuild_single.tmpl b/coil/data/templates/mako/coil_rebuild_single.tmpl index 5028313..b3822d3 100644 --- a/coil/data/templates/mako/coil_rebuild_single.tmpl +++ b/coil/data/templates/mako/coil_rebuild_single.tmpl @@ -25,13 +25,11 @@ $(document).ready(function() { fs = $('.build-status-icon'); fsc = $('.build-status-caption'); - if (${bstatus} == 1) { + if (${status} == 1) { fs.removeClass('fa-cog'); fs.addClass('fa-check'); fsc.addClass('text-success'); - clearInterval(intID); } else { - pb.addClass('progress-bar-danger'); fs.removeClass('fa-cog'); fs.addClass('fa-times'); fsc.addClass('text-danger'); diff --git a/coil/forms.py b/coil/forms.py index b0cfd1c..12dcead 100644 --- a/coil/forms.py +++ b/coil/forms.py @@ -73,11 +73,19 @@ class UserImportForm(Form): """A user import form.""" tsv = FileField("TSV File") + class UserEditForm(Form): """A user editor form, used for CSRF protection only.""" pass + class PermissionsForm(Form): """A permissions form, used for CSRF protection only.""" pass + + +class PwdHashForm(Form): + """A password hash form.""" + newpwd1 = TextField('New password', validators=[Required()]) + newpwd2 = TextField('Repeat new password', validators=[Required()]) diff --git a/coil/tasks.py b/coil/tasks.py index 49d0dca..f11af82 100644 --- a/coil/tasks.py +++ b/coil/tasks.py @@ -88,7 +88,7 @@ def orphans(dburl, sitedir): job = get_current_job(db) job.meta.update({'out': '', 'return': None, 'status': None}) job.save() - returncode, out = orphans_single() + returncode, out = orphans_single(default_exec=True) job.meta.update({'out': out, 'return': returncode, 'status': returncode == 0}) @@ -102,15 +102,25 @@ def build_single(mode): amode = ['-a'] else: amode = [] - p = subprocess.Popen([executable, '-m', 'nikola', 'build'] + amode, + if executable.endswith('uwsgi'): + # hack, might fail in some environments! + _executable = executable[:-5] + 'python' + else: + _executable = executable + p = subprocess.Popen([_executable, '-m', 'nikola', 'build'] + amode, stderr=subprocess.PIPE) p.wait() out = ''.join(p.stderr.readlines()) return (p.returncode == 0), out -def orphans_single(): +def orphans_single(default_exec=False): """Remove all orphans in the site, in the single user-mode.""" - p = subprocess.Popen([executable, '-m', 'nikola', 'orphans'], + if not default_exec and executable.endswith('uwsgi'): + # default_exec => rq => sys.executable is sane + _executable = executable[:-5] + 'python' + else: + _executable = executable + p = subprocess.Popen([_executable, '-m', 'nikola', 'orphans'], stdout=subprocess.PIPE) p.wait() files = [l.strip() for l in p.stdout.readlines()] diff --git a/coil/web.py b/coil/web.py index 499ae3f..7dccce8 100644 --- a/coil/web.py +++ b/coil/web.py @@ -47,7 +47,7 @@ from coil.utils import USER_FIELDS, PERMISSIONS, PERMISSIONS_E, SiteProxy from coil.forms import (LoginForm, NewPostForm, NewPageForm, DeleteForm, UserDeleteForm, UserEditForm, AccountForm, - PermissionsForm, UserImportForm) + PermissionsForm, UserImportForm, PwdHashForm) _site = None site = None @@ -58,7 +58,7 @@ def scan_site(): """Rescan the site.""" - site.scan_posts(really=True, quiet=True) + site.scan_posts(really=True, ignore_quit=False, quiet=True) def configure_url(url): @@ -76,7 +76,7 @@ def configure_site(): nikola.__main__._RETURN_DOITNIKOLA = True _dn = nikola.__main__.main([]) - _dn.sub_cmds = _dn.get_commands() + _dn.sub_cmds = _dn.get_cmds() _site = _dn.nikola app.config['NIKOLA_ROOT'] = os.getcwd() app.config['DEBUG'] = False @@ -118,11 +118,12 @@ def configure_site(): app.secret_key = _site.config.get('COIL_SECRET_KEY') app.config['COIL_URL'] = _site.config.get('COIL_URL') - app.config['COIL_SINGLE'] = _site.config.get('COIL_SINGLE', False) + app.config['COIL_LIMITED'] = _site.config.get('COIL_LIMITED', False) app.config['REDIS_URL'] = _site.config.get('COIL_REDIS_URL', 'redis://localhost:6379/0') - if app.config['COIL_SINGLE']: + if app.config['COIL_LIMITED']: app.config['COIL_USERS'] = _site.config.get('COIL_USERS', {}) + _site.coil_needs_rebuild = '0' else: db = redis.StrictRedis.from_url(app.config['REDIS_URL']) q = rq.Queue(name='coil', connection=db) @@ -180,9 +181,17 @@ def configure_site(): # Inject tmpl_dir low in the theme chain _site.template_system.inject_directory(tmpl_dir) + # Commands proxy (only for Nikola commands) + _site.commands = nikola.utils.Commands( + _site.doit, + None, + {'cmds': _site._commands} + ) + # Site proxy - if app.config['COIL_SINGLE']: + if app.config['COIL_LIMITED']: site = _site + scan_site() else: site = SiteProxy(db, _site, app.logger) @@ -229,6 +238,7 @@ def check_old_password(pwdhash, password): bcrypt = Bcrypt(app) return bcrypt.check_password_hash(pwdhash, password) + def generate_menu(): """Generate ``menu`` with the rebuild link. @@ -474,7 +484,7 @@ def find_user_by_name(username): if uid: return get_user(uid) else: - for u in app.config['COIL_USERS']: + for uid, u in app.config['COIL_USERS'].items(): if u['username'] == username: return get_user(uid) @@ -638,6 +648,7 @@ def edit(path): meta.pop('_wysihtml5_mode', '') try: meta['author'] = get_user(meta['author.uid']).realname + current_auid = int(meta['author.uid']) author_change_success = True except: author_change_success = False @@ -683,9 +694,9 @@ def edit(path): if active == '1': users.append((u, realname)) else: - for u, d in app.config['COIL_USERS'].values(): + for u, d in app.config['COIL_USERS'].items(): if d['active']: - users.append((u, d['realname'])) + users.append((int(u), d['realname'])) context['users'] = sorted(users) context['current_auid'] = current_auid context['title'] = 'Editing {0}'.format(post.title()) @@ -786,6 +797,7 @@ def rebuild(mode=''): else: status, outputb = coil.tasks.build_single(mode) _, outputo = coil.tasks.orphans_single() + site.coil_needs_rebuild = '0' return render('coil_rebuild_single.tmpl', {'title': 'Rebuild', 'status': '1' if status else '0', 'outputb': outputb, @@ -896,6 +908,15 @@ def acp_account(): else: alert = '' alert_status = '' + + if db is None: + form = PwdHashForm() + return render('coil_account_single.tmpl', + context={'title': 'My account', + 'form': form, + 'alert': alert, + 'alert_status': alert_status}) + action = 'edit' form = AccountForm() if request.method == 'POST': @@ -932,6 +953,23 @@ def acp_account(): 'form': form}) +@app.route('/account/pwdhash', methods=['POST']) +def acp_pwdhash(): + form = PwdHashForm() + if not form.validate(): + return error("Bad Request", 400) + data = request.form + if data['newpwd1'] == data['newpwd2']: + pwdhash = password_hash(data['newpwd1']) + status = True + else: + pwdhash = None + status = False + return render('coil_pwdhash.tmpl', context={'title': 'My account', + 'pwdhash': pwdhash, + 'status': status, + 'form': form}) + @app.route('/users/') @login_required def acp_users(): diff --git a/docs/admin/setup.rst b/docs/admin/setup.rst index 344b115..e348097 100644 --- a/docs/admin/setup.rst +++ b/docs/admin/setup.rst @@ -18,42 +18,14 @@ As such, you must configure Nikola first before you start Coil. Virtualenv ========== -Create a virtualenv in ``/var/coil`` and install Coil in it. +Create a virtualenv in ``/var/coil`` and install Coil, Nikola and uWSGI in it. .. code-block:: console # virtualenv-2.7 /var/coil # cd /var/coil # source bin/activate - # pip install coil uwsgi - -Redis -===== - -You need to set up a `Redis `_ server. Make sure it starts -at boot. - -RQ -== - -You need to set up a `RQ `_ worker. Make sure it starts -at boot, after Redis. Here is a sample ``.service`` file for systemd: - -.. code-block:: ini - - [Unit] - Description=RQWorker Service - After=redis.service - - [Service] - Type=simple - ExecStart=/var/coil/bin/rqworker coil - User=nobody - Group=nobody - - [Install] - WantedBy=multi-user.target - + # pip install nikola coil uwsgi Nikola and ``conf.py`` ====================== @@ -79,37 +51,102 @@ Then, you must make some changes to the config: **Store it in a safe place** — git is not one! You can use ``os.urandom(24)`` to generate something good. * ``COIL_URL`` — the URL under which Coil can be accessed. - * ``COIL_REDIS_URL`` — the URL of your Redis database. - * Modify ``POSTS`` and ``PAGES``, replacing ``.txt`` by ``.html``. + * ``_MAKO_DISABLE_CACHING = True`` + * Modify ``POSTS`` and ``PAGES``, replacing ``.txt`` with ``.html``. + * You must set the mode (Limited vs Full) and configure it accordingly — see + next section for details. -Redis URL syntax +CSS for the site ---------------- -* ``redis://[:password]@localhost:6379/0`` (TCP) -* ``rediss://[:password]@localhost:6379/0`` (TCP over SSL) -* ``unix://[:password]@/path/to/socket.sock?db=0`` (Unix socket) +You must add `some CSS`__ for wysihtml5. The easiest way to do this +is by downloading the raw ``.css`` file and saving it as ``files/assets/css/custom.css``. -The default URL is ``redis://localhost:6379/0``. +__ https://github.com/Voog/wysihtml/blob/master/examples/css/stylesheet.css -CSS for the site ----------------- -Finally, you must add `some CSS`__ for wysihtml5. The easiest way to do this -is by downloading the raw ``.css`` file as ``files/assets/css/custom.css``. +Limited Mode vs. Full Mode +========================== -__ https://github.com/Voog/wysihtml/blob/master/examples/css/stylesheet.css +Coil can run in two modes: Limited and Full. -First build -=========== +**Limited Mode**: -When you are done configuring nikola, run ``nikola build``. +* does not require a database, is easier to setup +* stores its user data in ``conf.py`` (no ability to modify users on-the-fly) +* MUST run as a single process (``processes=1`` in uWSGI config) -.. code-block:: console +**Full Mode**: - # nikola build +* requires Redis and RQ installed and running +* stores its user data in the Redis database (you can modify users on-the-fly) +* may run as multiple processes + +Configuring Limited Mode +------------------------ + +You need to add the following to your config file: + +.. code:: python + + COIL_LIMITED = True + COIL_USERS = { + '1': { + 'username': 'admin', + 'realname': 'Website Administrator', + 'password': '$bcrypt-sha256$2a,12$St3N7xoStL7Doxpvz78Jve$3vKfveUNhMNhvaFEfJllWEarb5oNgNu', + 'must_change_password': False, + 'email': 'info@getnikola.com', + 'active': True, + 'is_admin': True, + 'can_edit_all_posts': True, + 'wants_all_posts': True, + 'can_upload_attachments': True, + 'can_rebuild_site': True, + 'can_transfer_post_authorship': True, + }, + } + +The default user is ``admin`` with the password ``admin``. New users can be +created by creating a similar dict. Password hashes can be calculated on the +*Account* page. Note that you are responsible for changing user passwords +(users should provide you with hashes and you must add them manually and +restart Coil) — consider not setting ``must_change_password`` in Limited mode. + +Configuring Full Mode +--------------------- + +Full Mode requires much more extra configuration. + +Redis +~~~~~ + +You need to set up a `Redis `_ server. Make sure it starts +at boot. + +RQ +~~ + +You need to set up a `RQ `_ worker. Make sure it starts +at boot, after Redis. Here is a sample ``.service`` file for systemd: + +.. code-block:: ini + + [Unit] + Description=RQWorker Service + After=redis.service + + [Service] + Type=simple + ExecStart=/var/coil/bin/rqworker coil + User=nobody + Group=nobody + + [Install] + WantedBy=multi-user.target Users -===== +~~~~~ Run ``coil write_users``: @@ -124,6 +161,28 @@ Run ``coil write_users``: You will be able to add more users and change the admin credentials (which you should do!) later. See also: :doc:`users`. +conf.py additions +~~~~~~~~~~~~~~~~~ + +You must add ``COIL_LIMITED = False`` and ``COIL_REDIS_URL``, which is an URL to +your Redis database. The accepted formats are: + +* ``redis://[:password]@localhost:6379/0`` (TCP) +* ``rediss://[:password]@localhost:6379/0`` (TCP over SSL) +* ``unix://[:password]@/path/to/socket.sock?db=0`` (Unix socket) + +The default URL is ``redis://localhost:6379/0``. + + +First build +=========== + +When you are done configuring Nikola and Coil, run ``nikola build``. + +.. code-block:: console + + # nikola build + Permissions =========== @@ -168,7 +227,11 @@ Sample uWSGI configuration: .. note:: - ``python2`` may also be ``python`` this depending on your environment. + ``python2`` may also be ``python`` depending on your environment. + +.. note:: + + ``processes`` MUST be set to 1 if running in Limited Mode. nginx ----- diff --git a/docs/user/account.rst b/docs/user/account.rst index a4b1201..f3cae78 100644 --- a/docs/user/account.rst +++ b/docs/user/account.rst @@ -1,9 +1,14 @@ Account ======= -You can edit your account in the Account tab. You can change your real name, +You can edit your account in the Account page. You can change your real name, e-mail and password. You can also set the preferences. +Some sites require you contact an administrator to change your details. +In this case, the Account page allows you to generate a hash to let your +password be changed securely (your administrator cannot recover the password +from the hash). + .. warning:: The real name is not changed on the posts already created. @@ -12,6 +17,7 @@ e-mail and password. You can also set the preferences. If you want to change your username, you must contact an administrator. + .. admonition:: Permissions In order to enable the “Show me posts by other users by default” preference, you must