changeset 8142:8f468d08f463

Merge stable
author Mads Kiilerich <mads@kiilerich.com>
date Wed, 22 Jan 2020 23:46:12 +0100
parents ed67d1df7125 (current diff) 28fa94f56370 (diff)
children 42ef4ea26efa
files kallithea/__init__.py kallithea/alembic/env.py kallithea/bin/kallithea_cli_base.py kallithea/bin/kallithea_cli_ssh.py kallithea/config/app_cfg.py kallithea/controllers/admin/users.py kallithea/lib/auth.py kallithea/lib/hooks.py kallithea/lib/ssh.py kallithea/lib/utils2.py kallithea/model/db.py kallithea/model/ssh_key.py kallithea/tests/functional/test_admin_users.py kallithea/tests/functional/test_login.py kallithea/tests/functional/test_my_account.py
diffstat 35 files changed, 204 insertions(+), 93 deletions(-) [+]
line wrap: on
line diff
--- a/.hgtags	Sat Jan 04 00:30:21 2020 +0100
+++ b/.hgtags	Wed Jan 22 23:46:12 2020 +0100
@@ -74,3 +74,4 @@
 19086c5de05f4984d7a90cd31624c45dd893f6bb 0.4.0
 da65398a62fff50f3d241796cbf17acdea2092ef 0.4.1
 bfa0b0a814644f0af3f492d17a9ed169cc3b89fe 0.5.0
+d01a8e92936dbd62c76505432f60efba432e9397 0.5.1
--- a/CONTRIBUTORS	Sat Jan 04 00:30:21 2020 +0100
+++ b/CONTRIBUTORS	Wed Jan 22 23:46:12 2020 +0100
@@ -1,11 +1,12 @@
 List of contributors to Kallithea project:
 
+    Thomas De Schampheleire <thomas.de_schampheleire@nokia.com> 2014-2020
+    Mads Kiilerich <mads@kiilerich.com> 2016-2020
     Andrej Shadura <andrew@shadura.me> 2012 2014-2017 2019
-    Thomas De Schampheleire <thomas.de_schampheleire@nokia.com> 2014-2019
     Étienne Gilli <etienne.gilli@gmail.com> 2015-2017 2019
-    Mads Kiilerich <mads@kiilerich.com> 2016-2019
     Allan Nordhøy <epost@anotheragency.no> 2017-2019
     ssantos <ssantos@web.de> 2018-2019
+    Adi Kriegisch <adi@cg.tuwien.ac.at> 2019
     Danni Randeris <danniranderis@gmail.com> 2019
     Edmund Wong <ewong@crazy-cat.org> 2019
     Elizabeth Sherrock <lizzyd710@gmail.com> 2019
@@ -15,6 +16,7 @@
     Mateusz Mendel <mendelm9@gmail.com> 2019
     Nathan <bonnemainsnathan@gmail.com> 2019
     Oleksandr Shtalinberg <o.shtalinberg@gmail.com> 2019
+    Private <adamantine.sword@gmail.com> 2019
     THANOS SIOURDAKIS <siourdakisthanos@gmail.com> 2019
     Wolfgang Scherer <wolfgang.scherer@gmx.de> 2019
     Христо Станев <hstanev@gmail.com> 2019
--- a/development.ini	Sat Jan 04 00:30:21 2020 +0100
+++ b/development.ini	Wed Jan 22 23:46:12 2020 +0100
@@ -90,10 +90,12 @@
 static_files = true
 
 ## Internationalization (see setup documentation for details)
-## By default, the language requested by the browser is used if available.
-#i18n.enabled = false
-## Fallback language, empty for English (valid values are the names of subdirectories in kallithea/i18n):
-i18n.lang =
+## By default, the languages requested by the browser are used if available, with English as default.
+## Set i18n.enabled=false to disable automatic language choice.
+#i18n.enabled = true
+## To Force a language, set i18n.enabled=false and specify the language in i18n.lang.
+## Valid values are the names of subdirectories in kallithea/i18n with a LC_MESSAGES/kallithea.mo
+#i18n.lang = en
 
 cache_dir = %(here)s/data
 index_dir = %(here)s/data/index
--- a/docs/conf.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/docs/conf.py	Wed Jan 22 23:46:12 2020 +0100
@@ -47,7 +47,7 @@
 
 # General information about the project.
 project = u'Kallithea'
-copyright = u'2010-2019 by various authors, licensed as GPLv3.'
+copyright = u'2010-2020 by various authors, licensed as GPLv3.'
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
--- a/docs/setup.rst	Sat Jan 04 00:30:21 2020 +0100
+++ b/docs/setup.rst	Wed Jan 22 23:46:12 2020 +0100
@@ -80,13 +80,12 @@
 language, as indicated by the browser. Thus, different users may see the
 application in different languages. If the requested language is not available
 (because the translation file for that language does not yet exist or is
-incomplete), the language specified in setting ``i18n.lang`` in the Kallithea
-configuration file is used as fallback. If no fallback language is explicitly
-specified, English is used.
+incomplete), English is used.
 
 If you want to disable automatic language detection and instead configure a
 fixed language regardless of user preference, set ``i18n.enabled = false`` and
-set ``i18n.lang`` to the desired language (or leave empty for English).
+specify another language by setting ``i18n.lang`` in the Kallithea
+configuration file.
 
 
 Using Kallithea with SSH
@@ -562,7 +561,7 @@
 
       ini = '/srv/kallithea/my.ini'
       from logging.config import fileConfig
-      fileConfig(ini)
+      fileConfig(ini, {'__file__': ini, 'here': '/srv/kallithea'})
       from paste.deploy import loadapp
       application = loadapp('config:' + ini)
 
@@ -578,7 +577,7 @@
 
       ini = '/srv/kallithea/kallithea.ini'
       from logging.config import fileConfig
-      fileConfig(ini)
+      fileConfig(ini, {'__file__': ini, 'here': '/srv/kallithea'})
       from paste.deploy import loadapp
       application = loadapp('config:' + ini)
 
--- a/kallithea/alembic/env.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/alembic/env.py	Wed Jan 22 23:46:12 2020 +0100
@@ -15,6 +15,7 @@
 # Alembic migration environment (configuration).
 
 import logging
+import os
 from logging.config import fileConfig
 
 from alembic import context
@@ -43,7 +44,9 @@
 # stamping during "kallithea-cli db-create"), config_file_name is not available,
 # and loggers are assumed to already have been configured.
 if config.config_file_name:
-    fileConfig(config.config_file_name, disable_existing_loggers=False)
+    fileConfig(config.config_file_name,
+        {'__file__': config.config_file_name, 'here': os.path.dirname(config.config_file_name)},
+        disable_existing_loggers=False)
 
 
 def include_in_autogeneration(object, name, type, reflected, compare_to):
--- a/kallithea/alembic/versions/4851d15bc437_db_migration_step_after_95c01895c006_.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/alembic/versions/4851d15bc437_db_migration_step_after_95c01895c006_.py	Wed Jan 22 23:46:12 2020 +0100
@@ -31,14 +31,20 @@
 
 
 def upgrade():
-    meta = sa.MetaData()
-    meta.reflect(bind=op.get_bind())
+    pass
+    # The following upgrade step turned out to be a bad idea. A later step
+    # "d7ec25b66e47_ssh_drop_usk_public_key_idx_again" will remove the index
+    # again if it exists ... but we shouldn't even try to create it.
 
-    if not any(i.name == 'usk_public_key_idx' for i in meta.tables['user_ssh_keys'].indexes):
-        with op.batch_alter_table('user_ssh_keys', schema=None) as batch_op:
-            batch_op.create_index('usk_public_key_idx', ['public_key'], unique=False)
+    #meta = sa.MetaData()
+    #meta.reflect(bind=op.get_bind())
+
+    #if not any(i.name == 'usk_public_key_idx' for i in meta.tables['user_ssh_keys'].indexes):
+    #    with op.batch_alter_table('user_ssh_keys', schema=None) as batch_op:
+    #        batch_op.create_index('usk_public_key_idx', ['public_key'], unique=False)
 
 
 def downgrade():
-    with op.batch_alter_table('user_ssh_keys', schema=None) as batch_op:
-        batch_op.drop_index('usk_public_key_idx')
+    if any(i.name == 'usk_public_key_idx' for i in meta.tables['user_ssh_keys'].indexes):
+        with op.batch_alter_table('user_ssh_keys', schema=None) as batch_op:
+            batch_op.drop_index('usk_public_key_idx')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/kallithea/alembic/versions/d7ec25b66e47_ssh_drop_usk_public_key_idx_again.py	Wed Jan 22 23:46:12 2020 +0100
@@ -0,0 +1,43 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""ssh: drop usk_public_key_idx again
+
+Revision ID: d7ec25b66e47
+Revises: 4851d15bc437
+Create Date: 2019-12-29 15:33:10.982003
+
+"""
+
+# The following opaque hexadecimal identifiers ("revisions") are used
+# by Alembic to track this migration script and its relations to others.
+revision = 'd7ec25b66e47'
+down_revision = '4851d15bc437'
+branch_labels = None
+depends_on = None
+
+import sqlalchemy as sa
+from alembic import op
+
+
+def upgrade():
+    meta = sa.MetaData()
+    meta.reflect(bind=op.get_bind())
+
+    if any(i.name == 'usk_public_key_idx' for i in meta.tables['user_ssh_keys'].indexes):
+        with op.batch_alter_table('user_ssh_keys', schema=None) as batch_op:
+            batch_op.drop_index('usk_public_key_idx')
+
+
+def downgrade():
+    pass
--- a/kallithea/bin/kallithea_cli_base.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/bin/kallithea_cli_base.py	Wed Jan 22 23:46:12 2020 +0100
@@ -72,7 +72,8 @@
                 path_to_ini_file = os.path.realpath(config_file)
                 kallithea.CONFIG = paste.deploy.appconfig('config:' + path_to_ini_file)
                 config_string = read_config(path_to_ini_file, strip_section_prefix=annotated.__name__)
-                logging.config.fileConfig(io.StringIO(config_string))
+                logging.config.fileConfig(io.StringIO(config_string),
+                    {'__file__': path_to_ini_file, 'here': os.path.dirname(path_to_ini_file)})
                 if config_file_initialize_app:
                     kallithea.config.middleware.make_app_without_logging(kallithea.CONFIG.global_conf, **kallithea.CONFIG.local_conf)
                 return annotated(*args, **kwargs)
--- a/kallithea/bin/kallithea_cli_iis.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/bin/kallithea_cli_iis.py	Wed Jan 22 23:46:12 2020 +0100
@@ -33,7 +33,8 @@
 def __ExtensionFactory__():
     from paste.deploy import loadapp
     from logging.config import fileConfig
-    fileConfig('%(inifile)s')
+    fileConfig('%(inifile)s', {'__file__': '%(inifile)s', 'here': '%(inifiledir)s'})
+
     application = loadapp('config:%(inifile)s')
 
     def app(environ, start_response):
@@ -75,6 +76,7 @@
     with open(dispatchfile, 'w') as f:
         f.write(dispath_py_template % {
             'inifile': config_file_abs.replace('\\', '\\\\'),
+            'inifiledir': os.path.dirname(config_file_abs).replace('\\', '\\\\'),
             'virtualdir': virtualdir,
             })
 
--- a/kallithea/bin/kallithea_cli_ssh.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/bin/kallithea_cli_ssh.py	Wed Jan 22 23:46:12 2020 +0100
@@ -24,7 +24,7 @@
 from kallithea.lib.utils2 import str2bool
 from kallithea.lib.vcs.backends.git.ssh import GitSshHandler
 from kallithea.lib.vcs.backends.hg.ssh import MercurialSshHandler
-from kallithea.model.ssh_key import SshKeyModel
+from kallithea.model.ssh_key import SshKeyModel, SshKeyModelException
 
 
 log = logging.getLogger(__name__)
@@ -82,5 +82,8 @@
 
     The file is usually maintained automatically, but this command will also re-write it.
     """
-
-    SshKeyModel().write_authorized_keys()
+    try:
+        SshKeyModel().write_authorized_keys()
+    except SshKeyModelException as e:
+        sys.stderr.write("%s\n" % e)
+        sys.exit(1)
--- a/kallithea/config/app_cfg.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/config/app_cfg.py	Wed Jan 22 23:46:12 2020 +0100
@@ -98,6 +98,11 @@
         # Disable transaction manager -- currently Kallithea takes care of transactions itself
         self['tm.enabled'] = False
 
+        # Set the i18n source language so TG doesn't search beyond 'en' in Accept-Language.
+        # Don't force the default here if configuration force something else.
+        if not self.get('i18n.lang'):
+            self['i18n.lang'] = 'en'
+
 
 base_config = KallitheaAppConfig()
 
--- a/kallithea/config/middleware.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/config/middleware.py	Wed Jan 22 23:46:12 2020 +0100
@@ -13,8 +13,6 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 """WSGI middleware initialization for the Kallithea application."""
 
-import logging.config
-
 from kallithea.config.app_cfg import base_config
 from kallithea.config.environment import load_environment
 
@@ -49,5 +47,4 @@
     ``app_conf`` contains all the application-specific settings (those defined
     under ``[app:main]``.
     """
-    logging.config.fileConfig(global_conf['__file__'])
     return make_app_without_logging(global_conf, full_stack=full_stack, **app_conf)
--- a/kallithea/controllers/admin/my_account.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/controllers/admin/my_account.py	Wed Jan 22 23:46:12 2020 +0100
@@ -285,9 +285,9 @@
 
     @IfSshEnabled
     def my_account_ssh_keys_delete(self):
-        public_key = request.POST.get('del_public_key')
+        fingerprint = request.POST.get('del_public_key_fingerprint')
         try:
-            SshKeyModel().delete(public_key, request.authuser.user_id)
+            SshKeyModel().delete(fingerprint, request.authuser.user_id)
             Session().commit()
             SshKeyModel().write_authorized_keys()
             h.flash(_("SSH key successfully deleted"), category='success')
--- a/kallithea/controllers/admin/users.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/controllers/admin/users.py	Wed Jan 22 23:46:12 2020 +0100
@@ -460,9 +460,9 @@
     def ssh_keys_delete(self, id):
         c.user = self._get_user_or_raise_if_default(id)
 
-        public_key = request.POST.get('del_public_key')
+        fingerprint = request.POST.get('del_public_key_fingerprint')
         try:
-            SshKeyModel().delete(public_key, c.user.user_id)
+            SshKeyModel().delete(fingerprint, c.user.user_id)
             Session().commit()
             SshKeyModel().write_authorized_keys()
             h.flash(_("SSH key successfully deleted"), category='success')
--- a/kallithea/controllers/login.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/controllers/login.py	Wed Jan 22 23:46:12 2020 +0100
@@ -210,12 +210,10 @@
 
         # The template needs the email address outside of the form.
         c.email = request.params.get('email')
-
+        c.timestamp = request.params.get('timestamp') or ''
+        c.token = request.params.get('token') or ''
         if not request.POST:
-            return htmlfill.render(
-                render('/password_reset_confirmation.html'),
-                defaults=dict(request.params),
-                encoding='UTF-8')
+            return render('/password_reset_confirmation.html')
 
         form = PasswordResetConfirmationForm()()
         try:
Binary file kallithea/i18n/en/LC_MESSAGES/kallithea.mo has changed
--- a/kallithea/lib/auth.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/lib/auth.py	Wed Jan 22 23:46:12 2020 +0100
@@ -28,6 +28,7 @@
 import itertools
 import logging
 import os
+import string
 
 import ipaddr
 from decorator import decorator
@@ -109,8 +110,9 @@
     :param password: password
     :param hashed: password in hashed form
     """
-
-    if is_windows:
+    # sha256 hashes will always be 64 hex chars
+    # bcrypt hashes will always contain $ (and be shorter)
+    if is_windows or len(hashed) == 64 and all(x in string.hexdigits for x in hashed):
         return hashlib.sha256(safe_bytes(password)).hexdigest() == hashed
     elif is_unix:
         import bcrypt
--- a/kallithea/lib/hooks.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/lib/hooks.py	Wed Jan 22 23:46:12 2020 +0100
@@ -26,6 +26,7 @@
 """
 
 import os
+import sys
 import time
 
 import mercurial.scmutil
@@ -33,7 +34,7 @@
 from kallithea.lib import helpers as h
 from kallithea.lib.exceptions import UserCreationError
 from kallithea.lib.utils import action_logger, make_ui
-from kallithea.lib.utils2 import ascii_str, get_hook_environment, safe_bytes, safe_str, safe_unicode
+from kallithea.lib.utils2 import HookEnvironmentError, ascii_str, get_hook_environment, safe_bytes, safe_str, safe_unicode
 from kallithea.lib.vcs.backends.base import EmptyChangeset
 from kallithea.model.db import Repository, User
 
@@ -333,7 +334,11 @@
 
 def handle_git_post_receive(repo_path, git_stdin_lines):
     """Called from Git post-receive hook"""
-    baseui, repo = _hook_environment(repo_path)
+    try:
+        baseui, repo = _hook_environment(repo_path)
+    except HookEnvironmentError as e:
+        sys.stderr.write("Skipping Kallithea Git post-recieve hook %r.\nGit was apparently not invoked by Kallithea: %s\n" % (sys.argv[0], e))
+        return 0
 
     # the post push hook should never use the cached instance
     scm_repo = repo.scm_instance_no_cache()
--- a/kallithea/lib/paster_commands/template.ini.mako	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/lib/paster_commands/template.ini.mako	Wed Jan 22 23:46:12 2020 +0100
@@ -185,10 +185,12 @@
 static_files = true
 
 <%text>## Internationalization (see setup documentation for details)</%text>
-<%text>## By default, the language requested by the browser is used if available.</%text>
-#i18n.enabled = false
-<%text>## Fallback language, empty for English (valid values are the names of subdirectories in kallithea/i18n):</%text>
-i18n.lang =
+<%text>## By default, the languages requested by the browser are used if available, with English as default.</%text>
+<%text>## Set i18n.enabled=false to disable automatic language choice.</%text>
+#i18n.enabled = true
+<%text>## To Force a language, set i18n.enabled=false and specify the language in i18n.lang.</%text>
+<%text>## Valid values are the names of subdirectories in kallithea/i18n with a LC_MESSAGES/kallithea.mo</%text>
+#i18n.lang = en
 
 cache_dir = %(here)s/data
 index_dir = %(here)s/data/index
--- a/kallithea/lib/ssh.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/lib/ssh.py	Wed Jan 22 23:46:12 2020 +0100
@@ -48,7 +48,7 @@
     >>> parse_pub_key('''AAAAB3NzaC1yc2EAAAALVGhpcyBpcyBmYWtlIQ''')
     Traceback (most recent call last):
     ...
-    SshKeyParseError: Incorrect SSH key - it must have both a key type and a base64 part
+    SshKeyParseError: Incorrect SSH key - it must have both a key type and a base64 part, like 'ssh-rsa ASRNeaZu4FA...xlJp='
     >>> parse_pub_key('''abc AAAAB3NzaC1yc2EAAAALVGhpcyBpcyBmYWtlIQ''')
     Traceback (most recent call last):
     ...
@@ -76,7 +76,7 @@
 
     parts = ssh_key.split(None, 2)
     if len(parts) < 2:
-        raise SshKeyParseError(_("Incorrect SSH key - it must have both a key type and a base64 part"))
+        raise SshKeyParseError(_("Incorrect SSH key - it must have both a key type and a base64 part, like 'ssh-rsa ASRNeaZu4FA...xlJp='"))
 
     keytype, keyvalue, comment = (parts + [''])[:3]
     if keytype not in ('ssh-rsa', 'ssh-dss', 'ssh-ed25519'):
@@ -99,6 +99,18 @@
 SSH_OPTIONS = 'no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding'
 
 
+def _safe_check(s, rec = re.compile('^[a-zA-Z0-9+/]+={0,2}$')):
+    """Return true if s really has the right content for base64 encoding and only contains safe characters
+    >>> _safe_check('asdf')
+    True
+    >>> _safe_check('as df')
+    False
+    >>> _safe_check('AAAAB3NzaC1yc2EAAAALVGhpcyBpcyBmYWtlIQ==')
+    True
+    """
+    return rec.match(s) is not None
+
+
 def authorized_keys_line(kallithea_cli_path, config_file, key):
     """
     Return a line as it would appear in .authorized_keys
@@ -116,6 +128,8 @@
         return '# Invalid Kallithea SSH key: %s %s\n' % (key.user.user_id, key.user_ssh_key_id)
     base64_key = ascii_str(base64.b64encode(key_bytes))
     assert '\n' not in base64_key
+    if not _safe_check(base64_key):
+        return '# Invalid Kallithea SSH key - bad base64 encoding: %s %s\n' % (key.user.user_id, key.user_ssh_key_id)
     return '%s,command="%s ssh-serve -c %s %s %s" %s %s\n' % (
         SSH_OPTIONS, kallithea_cli_path, config_file,
         key.user.user_id, key.user_ssh_key_id,
--- a/kallithea/lib/utils2.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/lib/utils2.py	Wed Jan 22 23:46:12 2020 +0100
@@ -430,6 +430,9 @@
     return str(_url)
 
 
+class HookEnvironmentError(Exception): pass
+
+
 def get_hook_environment():
     """
     Get hook context by deserializing the global KALLITHEA_EXTRAS environment
@@ -441,15 +444,16 @@
     """
 
     try:
-        extras = json.loads(os.environ['KALLITHEA_EXTRAS'])
+        kallithea_extras = os.environ['KALLITHEA_EXTRAS']
     except KeyError:
-        raise Exception("Environment variable KALLITHEA_EXTRAS not found")
+        raise HookEnvironmentError("Environment variable KALLITHEA_EXTRAS not found")
 
+    extras = json.loads(kallithea_extras)
     try:
-        for k in ['username', 'repository', 'scm', 'action', 'ip']:
+        for k in ['username', 'repository', 'scm', 'action', 'ip', 'config']:
             extras[k]
     except KeyError:
-        raise Exception('Missing key %s in KALLITHEA_EXTRAS %s' % (k, extras))
+        raise HookEnvironmentError('Missing key %s in KALLITHEA_EXTRAS %s' % (k, extras))
 
     return AttributeDict(extras)
 
--- a/kallithea/model/db.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/model/db.py	Wed Jan 22 23:46:12 2020 +0100
@@ -2523,7 +2523,6 @@
 class UserSshKeys(Base, BaseDbModel):
     __tablename__ = 'user_ssh_keys'
     __table_args__ = (
-        Index('usk_public_key_idx', 'public_key'),
         Index('usk_fingerprint_idx', 'fingerprint'),
         UniqueConstraint('fingerprint'),
         _table_args_default_dict
--- a/kallithea/model/ssh_key.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/model/ssh_key.py	Wed Jan 22 23:46:12 2020 +0100
@@ -30,6 +30,7 @@
 
 from kallithea.lib import ssh
 from kallithea.lib.utils2 import str2bool
+from kallithea.lib.vcs.exceptions import RepositoryError
 from kallithea.model.db import User, UserSshKeys
 from kallithea.model.meta import Session
 
@@ -37,7 +38,7 @@
 log = logging.getLogger(__name__)
 
 
-class SshKeyModelException(Exception):
+class SshKeyModelException(RepositoryError):
     """Exception raised by SshKeyModel methods to report errors"""
 
 
@@ -72,21 +73,19 @@
 
         return new_ssh_key
 
-    def delete(self, public_key, user=None):
+    def delete(self, fingerprint, user):
         """
-        Deletes given public_key, if user is set it also filters the object for
-        deletion by given user.
+        Deletes ssh key with given fingerprint for the given user.
         Will raise SshKeyModelException on errors
         """
-        ssh_key = UserSshKeys.query().filter(UserSshKeys._public_key == public_key)
+        ssh_key = UserSshKeys.query().filter(UserSshKeys.fingerprint == fingerprint)
 
-        if user:
-            user = User.guess_instance(user)
-            ssh_key = ssh_key.filter(UserSshKeys.user_id == user.user_id)
+        user = User.guess_instance(user)
+        ssh_key = ssh_key.filter(UserSshKeys.user_id == user.user_id)
 
         ssh_key = ssh_key.scalar()
         if ssh_key is None:
-            raise SshKeyModelException(_('SSH key %r not found') % public_key)
+            raise SshKeyModelException(_('SSH key with fingerprint %r found') % fingerprint)
         Session().delete(ssh_key)
 
     def get_ssh_keys(self, user):
@@ -116,7 +115,7 @@
         # Now, test that the directory is or was created in a readable way by previous.
         if not (os.path.isdir(authorized_keys_dir) and
                 os.access(authorized_keys_dir, os.W_OK)):
-            raise Exception("Directory of authorized_keys cannot be written to so authorized_keys file %s cannot be written" % (authorized_keys))
+            raise SshKeyModelException("Directory of authorized_keys cannot be written to so authorized_keys file %s cannot be written" % (authorized_keys))
 
         # Make sure we don't overwrite a key file with important content
         if os.path.exists(authorized_keys):
@@ -127,10 +126,11 @@
                     elif ssh.SSH_OPTIONS in l and ' ssh-serve ' in l:
                         pass # Kallithea entries are ok to overwrite
                     else:
-                        raise Exception("Safety check failed, found %r in %s - please review and remove it" % (l.strip(), authorized_keys))
+                        raise SshKeyModelException("Safety check failed, found %r line in %s - please remove it if Kallithea should manage the file" % (l.strip(), authorized_keys))
 
         fh, tmp_authorized_keys = tempfile.mkstemp('.authorized_keys', dir=os.path.dirname(authorized_keys))
         with os.fdopen(fh, 'w') as f:
+            f.write("# WARNING: This .ssh/authorized_keys file is managed by Kallithea. Manual editing or adding new entries will make Kallithea back off.\n")
             for key in UserSshKeys.query().join(UserSshKeys.user).filter(User.active == True):
                 f.write(ssh.authorized_keys_line(kallithea_cli_path, config['__file__'], key))
         os.chmod(tmp_authorized_keys, stat.S_IRUSR | stat.S_IWUSR)
--- a/kallithea/templates/about.html	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/templates/about.html	Wed Jan 22 23:46:12 2020 +0100
@@ -24,12 +24,13 @@
   necessarily limited to the following:</p>
   <ul>
 
-  <li>Copyright &copy; 2012&ndash;2019, Mads Kiilerich</li>
+  <li>Copyright &copy; 2012&ndash;2020, Mads Kiilerich</li>
+  <li>Copyright &copy; 2014&ndash;2020, Thomas De Schampheleire</li>
   <li>Copyright &copy; 2012, 2014&ndash;2017, 2019, Andrej Shadura</li>
-  <li>Copyright &copy; 2014&ndash;2019, Thomas De Schampheleire</li>
   <li>Copyright &copy; 2015&ndash;2017, 2019, Étienne Gilli</li>
   <li>Copyright &copy; 2017&ndash;2019, Allan Nordhøy</li>
   <li>Copyright &copy; 2018&ndash;2019, ssantos</li>
+  <li>Copyright &copy; 2019, Adi Kriegisch</li>
   <li>Copyright &copy; 2019, Danni Randeris</li>
   <li>Copyright &copy; 2019, Edmund Wong</li>
   <li>Copyright &copy; 2019, Elizabeth Sherrock</li>
@@ -39,6 +40,7 @@
   <li>Copyright &copy; 2019, Mateusz Mendel</li>
   <li>Copyright &copy; 2019, Nathan</li>
   <li>Copyright &copy; 2019, Oleksandr Shtalinberg</li>
+  <li>Copyright &copy; 2019, Private</li>
   <li>Copyright &copy; 2019, THANOS SIOURDAKIS</li>
   <li>Copyright &copy; 2019, Wolfgang Scherer</li>
   <li>Copyright &copy; 2019, Христо Станев</li>
--- a/kallithea/templates/admin/my_account/my_account_ssh_keys.html	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/templates/admin/my_account/my_account_ssh_keys.html	Wed Jan 22 23:46:12 2020 +0100
@@ -23,7 +23,7 @@
             </td>
             <td>
                 ${h.form(url('my_account_ssh_keys_delete'))}
-                    ${h.hidden('del_public_key', ssh_key.public_key)}
+                    ${h.hidden('del_public_key_fingerprint', ssh_key.fingerprint)}
                     <button class="btn btn-danger btn-xs" type="submit"
                             onclick="return confirm('${_('Confirm to remove this SSH key: %s') % ssh_key.fingerprint}');">
                         <i class="icon-trashcan"></i>
--- a/kallithea/templates/admin/users/user_edit_ssh_keys.html	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/templates/admin/users/user_edit_ssh_keys.html	Wed Jan 22 23:46:12 2020 +0100
@@ -23,7 +23,7 @@
             </td>
             <td>
                 ${h.form(url('edit_user_ssh_keys_delete', id=c.user.user_id))}
-                    ${h.hidden('del_public_key', ssh_key.public_key)}
+                    ${h.hidden('del_public_key_fingerprint', ssh_key.fingerprint)}
                     <button class="btn btn-danger btn-xs" type="submit"
                             onclick="return confirm('${_('Confirm to remove this SSH key: %s') % ssh_key.fingerprint}');">
                         <i class="icon-trashcan"></i>
--- a/kallithea/templates/base/base.html	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/templates/base/base.html	Wed Jan 22 23:46:12 2020 +0100
@@ -23,7 +23,7 @@
             <a class="navbar-link" href="${h.url('kallithea_project_url')}" target="_blank">Kallithea</a>,
         %endif
         which is
-        <a class="navbar-link" href="${h.canonical_url('about')}#copyright">&copy; 2010&ndash;2019 by various authors &amp; licensed under GPLv3</a>.
+        <a class="navbar-link" href="${h.canonical_url('about')}#copyright">&copy; 2010&ndash;2020 by various authors &amp; licensed under GPLv3</a>.
         %if c.issues_url:
             &ndash; <a class="navbar-link" href="${c.issues_url}" target="_blank">${_('Support')}</a>
         %endif
--- a/kallithea/templates/password_reset_confirmation.html	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/templates/password_reset_confirmation.html	Wed Jan 22 23:46:12 2020 +0100
@@ -22,13 +22,13 @@
         ${h.form(h.url('reset_password_confirmation'), method='post')}
         <p>${_('You are about to set a new password for the email address %s.') % c.email}</p>
         <p>${_('Note that you must use the same browser session for this as the one used to request the password reset.')}</p>
-        ${h.hidden('email')}
-        ${h.hidden('timestamp')}
+        ${h.hidden('email', value=c.email)}
+        ${h.hidden('timestamp', value=c.timestamp)}
         <div class="form">
                 <div class="form-group">
                     <label class="control-label" for="token">${_('Code you received in the email')}:</label>
                     <div>
-                        ${h.text('token', class_='form-control')}
+                        ${h.text('token', value=c.token, class_='form-control')}
                     </div>
                 </div>
 
--- a/kallithea/tests/functional/test_admin_users.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/tests/functional/test_admin_users.py	Wed Jan 22 23:46:12 2020 +0100
@@ -556,7 +556,7 @@
         assert ssh_key.description == u'me@localhost'
 
         response = self.app.post(base.url('edit_user_ssh_keys_delete', id=user_id),
-                                 {'del_public_key': ssh_key.public_key,
+                                 {'del_public_key_fingerprint': ssh_key.fingerprint,
                                   '_session_csrf_secret_token': self.session_csrf_secret_token()})
         self.checkSessionFlash(response, 'SSH key successfully deleted')
         keys = UserSshKeys.query().all()
--- a/kallithea/tests/functional/test_login.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/tests/functional/test_login.py	Wed Jan 22 23:46:12 2020 +0100
@@ -3,8 +3,10 @@
 import time
 import urlparse
 
+import mock
 from tg.util.webtest import test_context
 
+import kallithea.lib.celerylib.tasks
 from kallithea.lib import helpers as h
 from kallithea.lib.auth import check_password
 from kallithea.lib.utils2 import generate_api_key
@@ -404,18 +406,38 @@
         Session().add(new)
         Session().commit()
 
-        response = self.app.post(base.url(controller='login',
-                                     action='password_reset'),
-                                 {'email': email,
-                                  '_session_csrf_secret_token': self.session_csrf_secret_token()})
+        token = UserModel().get_reset_password_token(
+            User.get_by_username(username), timestamp, self.session_csrf_secret_token())
+
+        collected = []
+        def mock_send_email(recipients, subject, body='', html_body='', headers=None, author=None):
+            collected.append((recipients, subject, body, html_body))
+
+        with mock.patch.object(kallithea.lib.celerylib.tasks, 'send_email', mock_send_email):
+            response = self.app.post(base.url(controller='login',
+                                         action='password_reset'),
+                                     {'email': email,
+                                      '_session_csrf_secret_token': self.session_csrf_secret_token()})
 
         self.checkSessionFlash(response, 'A password reset confirmation code has been sent')
 
+        ((recipients, subject, body, html_body),) = collected
+        assert recipients == ['username@example.com']
+        assert subject == 'Password reset link'
+        assert '\n%s\n' % token in body
+        (confirmation_url,) = (line for line in body.splitlines() if line.startswith('http://'))
+        assert ' href="%s"' % confirmation_url.replace('&', '&amp;').replace('@', '%40') in html_body
+
+        d = urlparse.parse_qs(urlparse.urlparse(confirmation_url).query)
+        assert d['token'] == [token]
+        assert d['timestamp'] == [str(timestamp)]
+        assert d['email'] == [email]
+
         response = response.follow()
 
         # BAD TOKEN
 
-        token = "bad"
+        bad_token = "bad"
 
         response = self.app.post(base.url(controller='login',
                                      action='password_reset_confirmation'),
@@ -423,7 +445,7 @@
                                   'timestamp': timestamp,
                                   'password': "p@ssw0rd",
                                   'password_confirm': "p@ssw0rd",
-                                  'token': token,
+                                  'token': bad_token,
                                   '_session_csrf_secret_token': self.session_csrf_secret_token(),
                                  })
         assert response.status == '200 OK'
@@ -431,20 +453,16 @@
 
         # GOOD TOKEN
 
-        # TODO: The token should ideally be taken from the mail sent
-        # above, instead of being recalculated.
-
-        token = UserModel().get_reset_password_token(
-            User.get_by_username(username), timestamp, self.session_csrf_secret_token())
-
-        response = self.app.get(base.url(controller='login',
-                                    action='password_reset_confirmation',
-                                    email=email,
-                                    timestamp=timestamp,
-                                    token=token))
+        response = self.app.get(confirmation_url)
         assert response.status == '200 OK'
         response.mustcontain("You are about to set a new password for the email address %s" % email)
+        response.mustcontain('<form action="%s" method="post">' % base.url(controller='login', action='password_reset_confirmation'))
+        response.mustcontain('value="%s"' % self.session_csrf_secret_token())
+        response.mustcontain('value="%s"' % token)
+        response.mustcontain('value="%s"' % timestamp)
+        response.mustcontain('value="username@example.com"')
 
+        # fake a submit of that form
         response = self.app.post(base.url(controller='login',
                                      action='password_reset_confirmation'),
                                  {'email': email,
--- a/kallithea/tests/functional/test_my_account.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/kallithea/tests/functional/test_my_account.py	Wed Jan 22 23:46:12 2020 +0100
@@ -289,7 +289,7 @@
         assert ssh_key.description == u'me@localhost'
 
         response = self.app.post(base.url('my_account_ssh_keys_delete'),
-                                 {'del_public_key': ssh_key.public_key,
+                                 {'del_public_key_fingerprint': ssh_key.fingerprint,
                                   '_session_csrf_secret_token': self.session_csrf_secret_token()})
         self.checkSessionFlash(response, 'SSH key successfully deleted')
         keys = UserSshKeys.query().all()
--- a/scripts/make-release	Sat Jan 04 00:30:21 2020 +0100
+++ b/scripts/make-release	Wed Jan 22 23:46:12 2020 +0100
@@ -46,7 +46,7 @@
 echo "Releasing Kallithea $version in directory $namerel"
 
 echo "Verify dist file content"
-diff -u <((hg mani | grep -v '^\.hg') | LANG=C sort) <(tar tf dist/Kallithea-$version.tar.gz | sed "s|^$namerel/||" | grep . | grep -v '^kallithea/i18n/.*/LC_MESSAGES/kallithea.mo$\|^Kallithea.egg-info/\|^PKG-INFO$\|/$' | LANG=C sort)
+diff -u <((hg mani | grep -v '^\.hg\|^kallithea/i18n/en/LC_MESSAGES/kallithea.mo$') | LANG=C sort) <(tar tf dist/Kallithea-$version.tar.gz | sed "s|^$namerel/||" | grep . | grep -v '^kallithea/i18n/.*/LC_MESSAGES/kallithea.mo$\|^Kallithea.egg-info/\|^PKG-INFO$\|/$' | LANG=C sort)
 
 echo "Verify docs build"
 python2 setup.py build_sphinx # the results are not actually used, but we want to make sure it builds
--- a/scripts/update-copyrights.py	Sat Jan 04 00:30:21 2020 +0100
+++ b/scripts/update-copyrights.py	Wed Jan 22 23:46:12 2020 +0100
@@ -100,6 +100,9 @@
     for year, name in all_entries:
         if name in no_entries or (name, year) in no_entries:
             continue
+        parts = name.split(' <', 1)
+        if len(parts) == 2:
+            name = parts[0] + ' <' + parts[1].lower()
         domain = name.split('@', 1)[-1].rstrip('>')
         if domain in domain_extra:
             name_years[domain_extra[domain]].add(year)
--- a/scripts/validate-minimum-dependency-versions	Sat Jan 04 00:30:21 2020 +0100
+++ b/scripts/validate-minimum-dependency-versions	Wed Jan 22 23:46:12 2020 +0100
@@ -34,7 +34,7 @@
 pip install -e . -r "$min_requirements" python-ldap python-pam 2> >(tee "$log" >&2)
 
 # Strip out the known Python 2.7 deprecation message.
-sed -i '/DEPRECATION: Python 2\.7 will reach the end of its life/d' "$log"
+sed -i '/DEPRECATION: Python 2\.7 /d' "$log"
 
 # Treat any message on stderr as a problem, for the caller to interpret.
 if [ -s "$log" ]; then