mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-04-20 18:59:08 +00:00
udm_user, homectl - replace crypt/legacycrypt with passlib (#11860)
* udm_user - replace crypt/legacycrypt with passlib The stdlib crypt module was removed in Python 3.13. Replace the crypt/legacycrypt import chain with passlib (already used elsewhere in the collection) and use CryptContext.verify() for password comparison. Fixes #4690 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add changelog fragment for PR 11860 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * remove redundant ignore file entries * udm_user, homectl - replace crypt/legacycrypt with _crypt module utils Add a new _crypt module_utils that abstracts password hashing and verification. It uses passlib when available, falling back to the stdlib crypt or legacycrypt, and raises ImportError if none of them can be imported. Both udm_user and homectl now use this shared utility, fixing compatibility with Python 3.13+. Fixes #4690 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add BOTMETA entry for _crypt module utils Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * _crypt - fix mypy errors and handle complete unavailability Replace CryptContext = object fallback (rejected by mypy) with a proper dummy class definition. Add has_crypt_context flag so modules can detect when no backend is available. Update both modules to import and check has_crypt_context instead of testing for None. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * adjsutments from review * Update plugins/modules/homectl.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/udm_user.py Co-authored-by: Felix Fontein <felix@fontein.de> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
parent
39f4cda6b5
commit
25b21183bb
11 changed files with 100 additions and 96 deletions
2
.github/BOTMETA.yml
vendored
2
.github/BOTMETA.yml
vendored
|
|
@ -379,6 +379,8 @@ files:
|
|||
$module_utils/jenkins.py:
|
||||
labels: jenkins
|
||||
maintainers: russoz
|
||||
$module_utils/_crypt.py:
|
||||
maintainers: russoz
|
||||
$module_utils/_lxc.py:
|
||||
maintainers: russoz
|
||||
$module_utils/_lvm.py:
|
||||
|
|
|
|||
6
changelogs/fragments/11860-udm_user-replace-crypt.yml
Normal file
6
changelogs/fragments/11860-udm_user-replace-crypt.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
bugfixes:
|
||||
- homectl - allow to use passlib instead of legacycrypt for Python 3.13+
|
||||
(https://github.com/ansible-collections/community.general/pull/11860).
|
||||
- udm_user - allow to use passlib instead of legacycrypt for Python 3.13+
|
||||
(https://github.com/ansible-collections/community.general/issues/4690,
|
||||
https://github.com/ansible-collections/community.general/pull/11860).
|
||||
45
plugins/module_utils/_crypt.py
Normal file
45
plugins/module_utils/_crypt.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Copyright (c) 2026, Alexei Znamensky <russoz@gmail.com>
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["CryptContext", "has_crypt_context"]
|
||||
|
||||
has_crypt_context = True
|
||||
try:
|
||||
from passlib.context import CryptContext
|
||||
|
||||
except ImportError:
|
||||
try:
|
||||
try:
|
||||
import crypt as _crypt_mod
|
||||
except ImportError:
|
||||
import legacycrypt as _crypt_mod
|
||||
|
||||
_SCHEME_TO_METHOD = {
|
||||
"sha512_crypt": _crypt_mod.METHOD_SHA512,
|
||||
"sha256_crypt": _crypt_mod.METHOD_SHA256,
|
||||
"md5_crypt": _crypt_mod.METHOD_MD5,
|
||||
"des_crypt": _crypt_mod.METHOD_CRYPT,
|
||||
}
|
||||
|
||||
class CryptContext: # type: ignore[no-redef]
|
||||
@staticmethod
|
||||
def verify(password, password_hash):
|
||||
return _crypt_mod.crypt(password, password_hash) == password_hash
|
||||
|
||||
@staticmethod
|
||||
def hash(password, scheme="sha512_crypt", rounds=10000):
|
||||
method = _SCHEME_TO_METHOD.get(scheme)
|
||||
if method is None:
|
||||
raise ValueError(f"Unsupported scheme: {scheme}")
|
||||
salt = _crypt_mod.mksalt(method, rounds=rounds)
|
||||
return _crypt_mod.crypt(password, salt)
|
||||
|
||||
except ImportError:
|
||||
|
||||
class CryptContext: # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
has_crypt_context = False
|
||||
|
|
@ -15,10 +15,11 @@ version_added: 4.4.0
|
|||
description:
|
||||
- Manages a user's home directory managed by systemd-homed.
|
||||
notes:
|
||||
- This module requires the deprecated L(crypt Python module, https://docs.python.org/3.12/library/crypt.html) library which
|
||||
was removed from Python 3.13. For Python 3.13 or newer, you need to install L(legacycrypt, https://pypi.org/project/legacycrypt/).
|
||||
- This module uses L(passlib, https://pypi.org/project/passlib/) for password hashing when available,
|
||||
falling back to the Python C(crypt) module or L(legacycrypt, https://pypi.org/project/legacycrypt/).
|
||||
requirements:
|
||||
- legacycrypt (on Python 3.13 or newer)
|
||||
- passlib (Python library, recommended), or legacycrypt on Python 3.13 or newer
|
||||
- It requires no dependency on Python 3.12 and earlier, but then it relies on the deprecated standard library C(crypt).
|
||||
extends_documentation_fragment:
|
||||
- community.general.attributes
|
||||
attributes:
|
||||
|
|
@ -268,38 +269,23 @@ data:
|
|||
"""
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, jsonify, missing_required_lib
|
||||
from ansible.module_utils.basic import AnsibleModule, jsonify
|
||||
from ansible.module_utils.common.text.formatters import human_to_bytes
|
||||
|
||||
CRYPT_IMPORT_ERROR: str | None
|
||||
try:
|
||||
import crypt
|
||||
except ImportError:
|
||||
HAS_CRYPT = False
|
||||
CRYPT_IMPORT_ERROR = traceback.format_exc()
|
||||
else:
|
||||
HAS_CRYPT = True
|
||||
CRYPT_IMPORT_ERROR = None
|
||||
from ansible_collections.community.general.plugins.module_utils import deps
|
||||
|
||||
LEGACYCRYPT_IMPORT_ERROR: str | None
|
||||
try:
|
||||
import legacycrypt
|
||||
with deps.declare("crypt_context"):
|
||||
from ansible_collections.community.general.plugins.module_utils._crypt import CryptContext, has_crypt_context
|
||||
|
||||
if not HAS_CRYPT:
|
||||
crypt = legacycrypt
|
||||
except ImportError:
|
||||
HAS_LEGACYCRYPT = False
|
||||
LEGACYCRYPT_IMPORT_ERROR = traceback.format_exc()
|
||||
else:
|
||||
HAS_LEGACYCRYPT = True
|
||||
LEGACYCRYPT_IMPORT_ERROR = None
|
||||
if not has_crypt_context:
|
||||
raise ImportError("Failed to import any of: passlib, crypt, legacycrypt")
|
||||
|
||||
|
||||
class Homectl:
|
||||
def __init__(self, module):
|
||||
def __init__(self, module, crypt_context):
|
||||
self.module = module
|
||||
self.crypt_context = crypt_context
|
||||
self.state = module.params["state"]
|
||||
self.name = module.params["name"]
|
||||
self.password = module.params["password"]
|
||||
|
|
@ -364,14 +350,10 @@ class Homectl:
|
|||
return self.module.run_command(cmd, data=record)
|
||||
|
||||
def _hash_password(self, password):
|
||||
method = crypt.METHOD_SHA512
|
||||
salt = crypt.mksalt(method, rounds=10000)
|
||||
pw_hash = crypt.crypt(password, salt)
|
||||
return pw_hash
|
||||
return self.crypt_context.hash(password, scheme="sha512_crypt", rounds=10000)
|
||||
|
||||
def _check_password(self, pwhash):
|
||||
hash = crypt.crypt(self.password, pwhash)
|
||||
return pwhash == hash
|
||||
return self.crypt_context.verify(self.password, pwhash)
|
||||
|
||||
def remove_user(self):
|
||||
cmd = [self.module.get_bin_path("homectl", True)]
|
||||
|
|
@ -616,13 +598,10 @@ def main():
|
|||
)
|
||||
module.run_command_environ_update = {"LANGUAGE": "C", "LC_ALL": "C"}
|
||||
|
||||
if not HAS_CRYPT and not HAS_LEGACYCRYPT:
|
||||
module.fail_json(
|
||||
msg=missing_required_lib("crypt (part of standard library up to Python 3.12) or legacycrypt (PyPI)"),
|
||||
exception=CRYPT_IMPORT_ERROR,
|
||||
)
|
||||
deps.validate(module)
|
||||
|
||||
homectl = Homectl(module)
|
||||
crypt_context = CryptContext(schemes=["sha512_crypt", "sha256_crypt", "md5_crypt", "des_crypt"])
|
||||
homectl = Homectl(module, crypt_context)
|
||||
homectl.result["state"] = homectl.state
|
||||
|
||||
# First we need to make sure homed service is active
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@ description:
|
|||
- This module allows to manage posix users on a univention corporate server (UCS). It uses the Python API of the UCS to
|
||||
create a new object or edit it.
|
||||
notes:
|
||||
- This module requires the deprecated L(crypt Python module, https://docs.python.org/3.12/library/crypt.html) library which
|
||||
was removed from Python 3.13. For Python 3.13 or newer, you need to install L(legacycrypt, https://pypi.org/project/legacycrypt/).
|
||||
- This module uses L(passlib, https://pypi.org/project/passlib/) for password hashing when available,
|
||||
falling back to the Python C(crypt) module or L(legacycrypt, https://pypi.org/project/legacycrypt/).
|
||||
requirements:
|
||||
- legacycrypt (on Python 3.13 or newer)
|
||||
- passlib (Python library, recommended), or legacycrypt on Python 3.13 or newer
|
||||
- It requires no dependency on Python 3.12 and earlier, but then it relies on the deprecated standard library C(crypt).
|
||||
extends_documentation_fragment:
|
||||
- community.general.attributes
|
||||
attributes:
|
||||
|
|
@ -316,10 +317,18 @@ EXAMPLES = r"""
|
|||
|
||||
RETURN = """#"""
|
||||
|
||||
import traceback
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.community.general.plugins.module_utils import deps
|
||||
|
||||
with deps.declare("crypt_context"):
|
||||
from ansible_collections.community.general.plugins.module_utils._crypt import CryptContext, has_crypt_context
|
||||
|
||||
if not has_crypt_context:
|
||||
raise ImportError("Failed to import any of: passlib, crypt, legacycrypt")
|
||||
|
||||
from ansible_collections.community.general.plugins.module_utils.univention_umc import (
|
||||
base_dn,
|
||||
|
|
@ -328,29 +337,6 @@ from ansible_collections.community.general.plugins.module_utils.univention_umc i
|
|||
umc_module_for_edit,
|
||||
)
|
||||
|
||||
CRYPT_IMPORT_ERROR: str | None
|
||||
try:
|
||||
import crypt
|
||||
except ImportError:
|
||||
HAS_CRYPT = False
|
||||
CRYPT_IMPORT_ERROR = traceback.format_exc()
|
||||
else:
|
||||
HAS_CRYPT = True
|
||||
CRYPT_IMPORT_ERROR = None
|
||||
|
||||
LEGACYCRYPT_IMPORT_ERROR: str | None
|
||||
try:
|
||||
import legacycrypt
|
||||
|
||||
if not HAS_CRYPT:
|
||||
crypt = legacycrypt
|
||||
except ImportError:
|
||||
HAS_LEGACYCRYPT = False
|
||||
LEGACYCRYPT_IMPORT_ERROR = traceback.format_exc()
|
||||
else:
|
||||
HAS_LEGACYCRYPT = True
|
||||
LEGACYCRYPT_IMPORT_ERROR = None
|
||||
|
||||
|
||||
def main():
|
||||
expiry = date.strftime(date.today() + timedelta(days=365), "%Y-%m-%d")
|
||||
|
|
@ -410,11 +396,9 @@ def main():
|
|||
required_if=([("state", "present", ["firstname", "lastname", "password"])]),
|
||||
)
|
||||
|
||||
if not HAS_CRYPT and not HAS_LEGACYCRYPT:
|
||||
module.fail_json(
|
||||
msg=missing_required_lib("crypt (part of standard library up to Python 3.12) or legacycrypt (PyPI)"),
|
||||
exception=LEGACYCRYPT_IMPORT_ERROR,
|
||||
)
|
||||
deps.validate(module)
|
||||
|
||||
crypt_context = CryptContext(schemes=["sha512_crypt", "sha256_crypt", "md5_crypt", "des_crypt"])
|
||||
|
||||
username = module.params["username"]
|
||||
position = module.params["position"]
|
||||
|
|
@ -474,7 +458,7 @@ def main():
|
|||
obj["password"] = password
|
||||
if module.params["update_password"] == "always":
|
||||
old_password = obj["password"].split("}", 2)[1]
|
||||
if crypt.crypt(password, old_password) != old_password:
|
||||
if not crypt_context.verify(password, old_password):
|
||||
obj["overridePWHistory"] = module.params["overridePWHistory"]
|
||||
obj["overridePWLength"] = module.params["overridePWLength"]
|
||||
obj["password"] = password
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
plugins/plugin_utils/unsafe.py pep8:E704
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
plugins/plugin_utils/unsafe.py pep8:E704
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_lxc.py pylint:ansible-bad-function # needs to use Popen() to stream logs
|
||||
plugins/modules/ansible_galaxy_install.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/gandi_livedns.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/interfaces_file.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/keycloak_realm_info.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
|
|
@ -13,7 +13,5 @@ plugins/modules/omapi_host.py validate-modules:bad-return-value-key # TODO: ren
|
|||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
plugins/module_utils/_crypt.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_crypt.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/module_utils/_lxc.py pylint:ansible-bad-function # needs to use Popen() to stream logs
|
||||
plugins/modules/ansible_galaxy_install.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/gandi_livedns.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/interfaces_file.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin
|
||||
plugins/modules/keycloak_realm_info.py validate-modules:bad-return-value-key # TODO: rename offending return values if possible, or adjust this comment in case the name is OK
|
||||
|
|
@ -13,7 +13,5 @@ plugins/modules/omapi_host.py validate-modules:bad-return-value-key # TODO: ren
|
|||
plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/parted.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
|
||||
plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt'
|
||||
plugins/modules/xfconf.py validate-modules:return-syntax-error
|
||||
tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue