Port "Allow users to click account renewal links multiple times without hitting an 'Invalid Token' page #74" from synapse-dinsic (#9832)
This attempts to be a direct port of https://github.com/matrix-org/synapse-dinsic/pull/74 to mainline. There was some fiddling required to deal with the changes that have been made to mainline since (mainly dealing with the split of `RegistrationWorkerStore` from `RegistrationStore`, and the changes made to `self.make_request` in test code).
This commit is contained in:
parent
e694a598f8
commit
71f0623de9
23
UPGRADE.rst
23
UPGRADE.rst
|
@ -85,6 +85,29 @@ for example:
|
|||
wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb
|
||||
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
|
||||
|
||||
Upgrading to v1.33.0
|
||||
====================
|
||||
|
||||
Account Validity HTML templates can now display a user's expiration date
|
||||
------------------------------------------------------------------------
|
||||
|
||||
This may affect you if you have enabled the account validity feature, and have made use of a
|
||||
custom HTML template specified by the ``account_validity.template_dir`` or ``account_validity.account_renewed_html_path``
|
||||
Synapse config options.
|
||||
|
||||
The template can now accept an ``expiration_ts`` variable, which represents the unix timestamp in milliseconds for the
|
||||
future date of which their account has been renewed until. See the
|
||||
`default template <https://github.com/matrix-org/synapse/blob/release-v1.33.0/synapse/res/templates/account_renewed.html>`_
|
||||
for an example of usage.
|
||||
|
||||
ALso note that a new HTML template, ``account_previously_renewed.html``, has been added. This is is shown to users
|
||||
when they attempt to renew their account with a valid renewal token that has already been used before. The default
|
||||
template contents can been found
|
||||
`here <https://github.com/matrix-org/synapse/blob/release-v1.33.0/synapse/res/templates/account_previously_renewed.html>`_,
|
||||
and can also accept an ``expiration_ts`` variable. This template replaces the error message users would previously see
|
||||
upon attempting to use a valid renewal token more than once.
|
||||
|
||||
|
||||
Upgrading to v1.32.0
|
||||
====================
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Don't return an error when a user attempts to renew their account multiple times with the same token. Instead, state when their account is set to expire. This change concerns the optional account validity feature.
|
|
@ -1175,69 +1175,6 @@ url_preview_accept_language:
|
|||
#
|
||||
#enable_registration: false
|
||||
|
||||
# Optional account validity configuration. This allows for accounts to be denied
|
||||
# any request after a given period.
|
||||
#
|
||||
# Once this feature is enabled, Synapse will look for registered users without an
|
||||
# expiration date at startup and will add one to every account it found using the
|
||||
# current settings at that time.
|
||||
# This means that, if a validity period is set, and Synapse is restarted (it will
|
||||
# then derive an expiration date from the current validity period), and some time
|
||||
# after that the validity period changes and Synapse is restarted, the users'
|
||||
# expiration dates won't be updated unless their account is manually renewed. This
|
||||
# date will be randomly selected within a range [now + period - d ; now + period],
|
||||
# where d is equal to 10% of the validity period.
|
||||
#
|
||||
account_validity:
|
||||
# The account validity feature is disabled by default. Uncomment the
|
||||
# following line to enable it.
|
||||
#
|
||||
#enabled: true
|
||||
|
||||
# The period after which an account is valid after its registration. When
|
||||
# renewing the account, its validity period will be extended by this amount
|
||||
# of time. This parameter is required when using the account validity
|
||||
# feature.
|
||||
#
|
||||
#period: 6w
|
||||
|
||||
# The amount of time before an account's expiry date at which Synapse will
|
||||
# send an email to the account's email address with a renewal link. By
|
||||
# default, no such emails are sent.
|
||||
#
|
||||
# If you enable this setting, you will also need to fill out the 'email' and
|
||||
# 'public_baseurl' configuration sections.
|
||||
#
|
||||
#renew_at: 1w
|
||||
|
||||
# The subject of the email sent out with the renewal link. '%(app)s' can be
|
||||
# used as a placeholder for the 'app_name' parameter from the 'email'
|
||||
# section.
|
||||
#
|
||||
# Note that the placeholder must be written '%(app)s', including the
|
||||
# trailing 's'.
|
||||
#
|
||||
# If this is not set, a default value is used.
|
||||
#
|
||||
#renew_email_subject: "Renew your %(app)s account"
|
||||
|
||||
# Directory in which Synapse will try to find templates for the HTML files to
|
||||
# serve to the user when trying to renew an account. If not set, default
|
||||
# templates from within the Synapse package will be used.
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
|
||||
# File within 'template_dir' giving the HTML to be displayed to the user after
|
||||
# they successfully renewed their account. If not set, default text is used.
|
||||
#
|
||||
#account_renewed_html_path: "account_renewed.html"
|
||||
|
||||
# File within 'template_dir' giving the HTML to be displayed when the user
|
||||
# tries to renew an account with an invalid renewal token. If not set,
|
||||
# default text is used.
|
||||
#
|
||||
#invalid_token_html_path: "invalid_token.html"
|
||||
|
||||
# Time that a user's session remains valid for, after they log in.
|
||||
#
|
||||
# Note that this is not currently compatible with guest logins.
|
||||
|
@ -1432,6 +1369,91 @@ account_threepid_delegates:
|
|||
#auto_join_rooms_for_guests: false
|
||||
|
||||
|
||||
## Account Validity ##
|
||||
|
||||
# Optional account validity configuration. This allows for accounts to be denied
|
||||
# any request after a given period.
|
||||
#
|
||||
# Once this feature is enabled, Synapse will look for registered users without an
|
||||
# expiration date at startup and will add one to every account it found using the
|
||||
# current settings at that time.
|
||||
# This means that, if a validity period is set, and Synapse is restarted (it will
|
||||
# then derive an expiration date from the current validity period), and some time
|
||||
# after that the validity period changes and Synapse is restarted, the users'
|
||||
# expiration dates won't be updated unless their account is manually renewed. This
|
||||
# date will be randomly selected within a range [now + period - d ; now + period],
|
||||
# where d is equal to 10% of the validity period.
|
||||
#
|
||||
account_validity:
|
||||
# The account validity feature is disabled by default. Uncomment the
|
||||
# following line to enable it.
|
||||
#
|
||||
#enabled: true
|
||||
|
||||
# The period after which an account is valid after its registration. When
|
||||
# renewing the account, its validity period will be extended by this amount
|
||||
# of time. This parameter is required when using the account validity
|
||||
# feature.
|
||||
#
|
||||
#period: 6w
|
||||
|
||||
# The amount of time before an account's expiry date at which Synapse will
|
||||
# send an email to the account's email address with a renewal link. By
|
||||
# default, no such emails are sent.
|
||||
#
|
||||
# If you enable this setting, you will also need to fill out the 'email' and
|
||||
# 'public_baseurl' configuration sections.
|
||||
#
|
||||
#renew_at: 1w
|
||||
|
||||
# The subject of the email sent out with the renewal link. '%(app)s' can be
|
||||
# used as a placeholder for the 'app_name' parameter from the 'email'
|
||||
# section.
|
||||
#
|
||||
# Note that the placeholder must be written '%(app)s', including the
|
||||
# trailing 's'.
|
||||
#
|
||||
# If this is not set, a default value is used.
|
||||
#
|
||||
#renew_email_subject: "Renew your %(app)s account"
|
||||
|
||||
# Directory in which Synapse will try to find templates for the HTML files to
|
||||
# serve to the user when trying to renew an account. If not set, default
|
||||
# templates from within the Synapse package will be used.
|
||||
#
|
||||
# The currently available templates are:
|
||||
#
|
||||
# * account_renewed.html: Displayed to the user after they have successfully
|
||||
# renewed their account.
|
||||
#
|
||||
# * account_previously_renewed.html: Displayed to the user if they attempt to
|
||||
# renew their account with a token that is valid, but that has already
|
||||
# been used. In this case the account is not renewed again.
|
||||
#
|
||||
# * invalid_token.html: Displayed to the user when they try to renew an account
|
||||
# with an unknown or invalid renewal token.
|
||||
#
|
||||
# See https://github.com/matrix-org/synapse/tree/master/synapse/res/templates for
|
||||
# default template contents.
|
||||
#
|
||||
# The file name of some of these templates can be configured below for legacy
|
||||
# reasons.
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
|
||||
# A custom file name for the 'account_renewed.html' template.
|
||||
#
|
||||
# If not set, the file is assumed to be named "account_renewed.html".
|
||||
#
|
||||
#account_renewed_html_path: "account_renewed.html"
|
||||
|
||||
# A custom file name for the 'invalid_token.html' template.
|
||||
#
|
||||
# If not set, the file is assumed to be named "invalid_token.html".
|
||||
#
|
||||
#invalid_token_html_path: "invalid_token.html"
|
||||
|
||||
|
||||
## Metrics ###
|
||||
|
||||
# Enable collection and rendering of performance metrics
|
||||
|
|
|
@ -79,7 +79,9 @@ class Auth:
|
|||
|
||||
self._auth_blocking = AuthBlocking(self.hs)
|
||||
|
||||
self._account_validity = hs.config.account_validity
|
||||
self._account_validity_enabled = (
|
||||
hs.config.account_validity.account_validity_enabled
|
||||
)
|
||||
self._track_appservice_user_ips = hs.config.track_appservice_user_ips
|
||||
self._macaroon_secret_key = hs.config.macaroon_secret_key
|
||||
|
||||
|
@ -222,7 +224,7 @@ class Auth:
|
|||
shadow_banned = user_info.shadow_banned
|
||||
|
||||
# Deny the request if the user account has expired.
|
||||
if self._account_validity.enabled and not allow_expired:
|
||||
if self._account_validity_enabled and not allow_expired:
|
||||
if await self.store.is_account_expired(
|
||||
user_info.user_id, self.clock.time_msec()
|
||||
):
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Any, Iterable, List, Optional
|
||||
|
||||
from synapse.config import (
|
||||
account_validity,
|
||||
api,
|
||||
appservice,
|
||||
auth,
|
||||
|
@ -59,6 +60,7 @@ class RootConfig:
|
|||
captcha: captcha.CaptchaConfig
|
||||
voip: voip.VoipConfig
|
||||
registration: registration.RegistrationConfig
|
||||
account_validity: account_validity.AccountValidityConfig
|
||||
metrics: metrics.MetricsConfig
|
||||
api: api.ApiConfig
|
||||
appservice: appservice.AppServiceConfig
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from synapse.config._base import Config, ConfigError
|
||||
|
||||
|
||||
class AccountValidityConfig(Config):
|
||||
section = "account_validity"
|
||||
|
||||
def read_config(self, config, **kwargs):
|
||||
account_validity_config = config.get("account_validity") or {}
|
||||
self.account_validity_enabled = account_validity_config.get("enabled", False)
|
||||
self.account_validity_renew_by_email_enabled = (
|
||||
"renew_at" in account_validity_config
|
||||
)
|
||||
|
||||
if self.account_validity_enabled:
|
||||
if "period" in account_validity_config:
|
||||
self.account_validity_period = self.parse_duration(
|
||||
account_validity_config["period"]
|
||||
)
|
||||
else:
|
||||
raise ConfigError("'period' is required when using account validity")
|
||||
|
||||
if "renew_at" in account_validity_config:
|
||||
self.account_validity_renew_at = self.parse_duration(
|
||||
account_validity_config["renew_at"]
|
||||
)
|
||||
|
||||
if "renew_email_subject" in account_validity_config:
|
||||
self.account_validity_renew_email_subject = account_validity_config[
|
||||
"renew_email_subject"
|
||||
]
|
||||
else:
|
||||
self.account_validity_renew_email_subject = "Renew your %(app)s account"
|
||||
|
||||
self.account_validity_startup_job_max_delta = (
|
||||
self.account_validity_period * 10.0 / 100.0
|
||||
)
|
||||
|
||||
if self.account_validity_renew_by_email_enabled:
|
||||
if not self.public_baseurl:
|
||||
raise ConfigError("Can't send renewal emails without 'public_baseurl'")
|
||||
|
||||
# Load account validity templates.
|
||||
account_validity_template_dir = account_validity_config.get("template_dir")
|
||||
|
||||
account_renewed_template_filename = account_validity_config.get(
|
||||
"account_renewed_html_path", "account_renewed.html"
|
||||
)
|
||||
invalid_token_template_filename = account_validity_config.get(
|
||||
"invalid_token_html_path", "invalid_token.html"
|
||||
)
|
||||
|
||||
# Read and store template content
|
||||
(
|
||||
self.account_validity_account_renewed_template,
|
||||
self.account_validity_account_previously_renewed_template,
|
||||
self.account_validity_invalid_token_template,
|
||||
) = self.read_templates(
|
||||
[
|
||||
account_renewed_template_filename,
|
||||
"account_previously_renewed.html",
|
||||
invalid_token_template_filename,
|
||||
],
|
||||
account_validity_template_dir,
|
||||
)
|
||||
|
||||
def generate_config_section(self, **kwargs):
|
||||
return """\
|
||||
## Account Validity ##
|
||||
|
||||
# Optional account validity configuration. This allows for accounts to be denied
|
||||
# any request after a given period.
|
||||
#
|
||||
# Once this feature is enabled, Synapse will look for registered users without an
|
||||
# expiration date at startup and will add one to every account it found using the
|
||||
# current settings at that time.
|
||||
# This means that, if a validity period is set, and Synapse is restarted (it will
|
||||
# then derive an expiration date from the current validity period), and some time
|
||||
# after that the validity period changes and Synapse is restarted, the users'
|
||||
# expiration dates won't be updated unless their account is manually renewed. This
|
||||
# date will be randomly selected within a range [now + period - d ; now + period],
|
||||
# where d is equal to 10% of the validity period.
|
||||
#
|
||||
account_validity:
|
||||
# The account validity feature is disabled by default. Uncomment the
|
||||
# following line to enable it.
|
||||
#
|
||||
#enabled: true
|
||||
|
||||
# The period after which an account is valid after its registration. When
|
||||
# renewing the account, its validity period will be extended by this amount
|
||||
# of time. This parameter is required when using the account validity
|
||||
# feature.
|
||||
#
|
||||
#period: 6w
|
||||
|
||||
# The amount of time before an account's expiry date at which Synapse will
|
||||
# send an email to the account's email address with a renewal link. By
|
||||
# default, no such emails are sent.
|
||||
#
|
||||
# If you enable this setting, you will also need to fill out the 'email' and
|
||||
# 'public_baseurl' configuration sections.
|
||||
#
|
||||
#renew_at: 1w
|
||||
|
||||
# The subject of the email sent out with the renewal link. '%(app)s' can be
|
||||
# used as a placeholder for the 'app_name' parameter from the 'email'
|
||||
# section.
|
||||
#
|
||||
# Note that the placeholder must be written '%(app)s', including the
|
||||
# trailing 's'.
|
||||
#
|
||||
# If this is not set, a default value is used.
|
||||
#
|
||||
#renew_email_subject: "Renew your %(app)s account"
|
||||
|
||||
# Directory in which Synapse will try to find templates for the HTML files to
|
||||
# serve to the user when trying to renew an account. If not set, default
|
||||
# templates from within the Synapse package will be used.
|
||||
#
|
||||
# The currently available templates are:
|
||||
#
|
||||
# * account_renewed.html: Displayed to the user after they have successfully
|
||||
# renewed their account.
|
||||
#
|
||||
# * account_previously_renewed.html: Displayed to the user if they attempt to
|
||||
# renew their account with a token that is valid, but that has already
|
||||
# been used. In this case the account is not renewed again.
|
||||
#
|
||||
# * invalid_token.html: Displayed to the user when they try to renew an account
|
||||
# with an unknown or invalid renewal token.
|
||||
#
|
||||
# See https://github.com/matrix-org/synapse/tree/master/synapse/res/templates for
|
||||
# default template contents.
|
||||
#
|
||||
# The file name of some of these templates can be configured below for legacy
|
||||
# reasons.
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
|
||||
# A custom file name for the 'account_renewed.html' template.
|
||||
#
|
||||
# If not set, the file is assumed to be named "account_renewed.html".
|
||||
#
|
||||
#account_renewed_html_path: "account_renewed.html"
|
||||
|
||||
# A custom file name for the 'invalid_token.html' template.
|
||||
#
|
||||
# If not set, the file is assumed to be named "invalid_token.html".
|
||||
#
|
||||
#invalid_token_html_path: "invalid_token.html"
|
||||
"""
|
|
@ -299,7 +299,7 @@ class EmailConfig(Config):
|
|||
"client_base_url", email_config.get("riot_base_url", None)
|
||||
)
|
||||
|
||||
if self.account_validity.renew_by_email_enabled:
|
||||
if self.account_validity_renew_by_email_enabled:
|
||||
expiry_template_html = email_config.get(
|
||||
"expiry_template_html", "notice_expiry.html"
|
||||
)
|
||||
|
|
|
@ -12,8 +12,8 @@
|
|||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from ._base import RootConfig
|
||||
from .account_validity import AccountValidityConfig
|
||||
from .api import ApiConfig
|
||||
from .appservice import AppServiceConfig
|
||||
from .auth import AuthConfig
|
||||
|
@ -68,6 +68,7 @@ class HomeServerConfig(RootConfig):
|
|||
CaptchaConfig,
|
||||
VoipConfig,
|
||||
RegistrationConfig,
|
||||
AccountValidityConfig,
|
||||
MetricsConfig,
|
||||
ApiConfig,
|
||||
AppServiceConfig,
|
||||
|
|
|
@ -12,74 +12,12 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from synapse.api.constants import RoomCreationPreset
|
||||
from synapse.config._base import Config, ConfigError
|
||||
from synapse.types import RoomAlias, UserID
|
||||
from synapse.util.stringutils import random_string_with_symbols, strtobool
|
||||
|
||||
|
||||
class AccountValidityConfig(Config):
|
||||
section = "accountvalidity"
|
||||
|
||||
def __init__(self, config, synapse_config):
|
||||
if config is None:
|
||||
return
|
||||
super().__init__()
|
||||
self.enabled = config.get("enabled", False)
|
||||
self.renew_by_email_enabled = "renew_at" in config
|
||||
|
||||
if self.enabled:
|
||||
if "period" in config:
|
||||
self.period = self.parse_duration(config["period"])
|
||||
else:
|
||||
raise ConfigError("'period' is required when using account validity")
|
||||
|
||||
if "renew_at" in config:
|
||||
self.renew_at = self.parse_duration(config["renew_at"])
|
||||
|
||||
if "renew_email_subject" in config:
|
||||
self.renew_email_subject = config["renew_email_subject"]
|
||||
else:
|
||||
self.renew_email_subject = "Renew your %(app)s account"
|
||||
|
||||
self.startup_job_max_delta = self.period * 10.0 / 100.0
|
||||
|
||||
if self.renew_by_email_enabled:
|
||||
if "public_baseurl" not in synapse_config:
|
||||
raise ConfigError("Can't send renewal emails without 'public_baseurl'")
|
||||
|
||||
template_dir = config.get("template_dir")
|
||||
|
||||
if not template_dir:
|
||||
template_dir = pkg_resources.resource_filename("synapse", "res/templates")
|
||||
|
||||
if "account_renewed_html_path" in config:
|
||||
file_path = os.path.join(template_dir, config["account_renewed_html_path"])
|
||||
|
||||
self.account_renewed_html_content = self.read_file(
|
||||
file_path, "account_validity.account_renewed_html_path"
|
||||
)
|
||||
else:
|
||||
self.account_renewed_html_content = (
|
||||
"<html><body>Your account has been successfully renewed.</body><html>"
|
||||
)
|
||||
|
||||
if "invalid_token_html_path" in config:
|
||||
file_path = os.path.join(template_dir, config["invalid_token_html_path"])
|
||||
|
||||
self.invalid_token_html_content = self.read_file(
|
||||
file_path, "account_validity.invalid_token_html_path"
|
||||
)
|
||||
else:
|
||||
self.invalid_token_html_content = (
|
||||
"<html><body>Invalid renewal token.</body><html>"
|
||||
)
|
||||
|
||||
|
||||
class RegistrationConfig(Config):
|
||||
section = "registration"
|
||||
|
||||
|
@ -92,10 +30,6 @@ class RegistrationConfig(Config):
|
|||
str(config["disable_registration"])
|
||||
)
|
||||
|
||||
self.account_validity = AccountValidityConfig(
|
||||
config.get("account_validity") or {}, config
|
||||
)
|
||||
|
||||
self.registrations_require_3pid = config.get("registrations_require_3pid", [])
|
||||
self.allowed_local_3pids = config.get("allowed_local_3pids", [])
|
||||
self.enable_3pid_lookup = config.get("enable_3pid_lookup", True)
|
||||
|
@ -207,69 +141,6 @@ class RegistrationConfig(Config):
|
|||
#
|
||||
#enable_registration: false
|
||||
|
||||
# Optional account validity configuration. This allows for accounts to be denied
|
||||
# any request after a given period.
|
||||
#
|
||||
# Once this feature is enabled, Synapse will look for registered users without an
|
||||
# expiration date at startup and will add one to every account it found using the
|
||||
# current settings at that time.
|
||||
# This means that, if a validity period is set, and Synapse is restarted (it will
|
||||
# then derive an expiration date from the current validity period), and some time
|
||||
# after that the validity period changes and Synapse is restarted, the users'
|
||||
# expiration dates won't be updated unless their account is manually renewed. This
|
||||
# date will be randomly selected within a range [now + period - d ; now + period],
|
||||
# where d is equal to 10%% of the validity period.
|
||||
#
|
||||
account_validity:
|
||||
# The account validity feature is disabled by default. Uncomment the
|
||||
# following line to enable it.
|
||||
#
|
||||
#enabled: true
|
||||
|
||||
# The period after which an account is valid after its registration. When
|
||||
# renewing the account, its validity period will be extended by this amount
|
||||
# of time. This parameter is required when using the account validity
|
||||
# feature.
|
||||
#
|
||||
#period: 6w
|
||||
|
||||
# The amount of time before an account's expiry date at which Synapse will
|
||||
# send an email to the account's email address with a renewal link. By
|
||||
# default, no such emails are sent.
|
||||
#
|
||||
# If you enable this setting, you will also need to fill out the 'email' and
|
||||
# 'public_baseurl' configuration sections.
|
||||
#
|
||||
#renew_at: 1w
|
||||
|
||||
# The subject of the email sent out with the renewal link. '%%(app)s' can be
|
||||
# used as a placeholder for the 'app_name' parameter from the 'email'
|
||||
# section.
|
||||
#
|
||||
# Note that the placeholder must be written '%%(app)s', including the
|
||||
# trailing 's'.
|
||||
#
|
||||
# If this is not set, a default value is used.
|
||||
#
|
||||
#renew_email_subject: "Renew your %%(app)s account"
|
||||
|
||||
# Directory in which Synapse will try to find templates for the HTML files to
|
||||
# serve to the user when trying to renew an account. If not set, default
|
||||
# templates from within the Synapse package will be used.
|
||||
#
|
||||
#template_dir: "res/templates"
|
||||
|
||||
# File within 'template_dir' giving the HTML to be displayed to the user after
|
||||
# they successfully renewed their account. If not set, default text is used.
|
||||
#
|
||||
#account_renewed_html_path: "account_renewed.html"
|
||||
|
||||
# File within 'template_dir' giving the HTML to be displayed when the user
|
||||
# tries to renew an account with an invalid renewal token. If not set,
|
||||
# default text is used.
|
||||
#
|
||||
#invalid_token_html_path: "invalid_token.html"
|
||||
|
||||
# Time that a user's session remains valid for, after they log in.
|
||||
#
|
||||
# Note that this is not currently compatible with guest logins.
|
||||
|
|
|
@ -17,7 +17,7 @@ import email.utils
|
|||
import logging
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
from synapse.api.errors import StoreError, SynapseError
|
||||
from synapse.logging.context import make_deferred_yieldable
|
||||
|
@ -39,28 +39,44 @@ class AccountValidityHandler:
|
|||
self.sendmail = self.hs.get_sendmail()
|
||||
self.clock = self.hs.get_clock()
|
||||
|
||||
self._account_validity = self.hs.config.account_validity
|
||||
self._account_validity_enabled = (
|
||||
hs.config.account_validity.account_validity_enabled
|
||||
)
|
||||
self._account_validity_renew_by_email_enabled = (
|
||||
hs.config.account_validity.account_validity_renew_by_email_enabled
|
||||
)
|
||||
|
||||
self._account_validity_period = None
|
||||
if self._account_validity_enabled:
|
||||
self._account_validity_period = (
|
||||
hs.config.account_validity.account_validity_period
|
||||
)
|
||||
|
||||
if (
|
||||
self._account_validity.enabled
|
||||
and self._account_validity.renew_by_email_enabled
|
||||
self._account_validity_enabled
|
||||
and self._account_validity_renew_by_email_enabled
|
||||
):
|
||||
# Don't do email-specific configuration if renewal by email is disabled.
|
||||
self._template_html = self.config.account_validity_template_html
|
||||
self._template_text = self.config.account_validity_template_text
|
||||
self._template_html = (
|
||||
hs.config.account_validity.account_validity_template_html
|
||||
)
|
||||
self._template_text = (
|
||||
hs.config.account_validity.account_validity_template_text
|
||||
)
|
||||
account_validity_renew_email_subject = (
|
||||
hs.config.account_validity.account_validity_renew_email_subject
|
||||
)
|
||||
|
||||
try:
|
||||
app_name = self.hs.config.email_app_name
|
||||
app_name = hs.config.email_app_name
|
||||
|
||||
self._subject = self._account_validity.renew_email_subject % {
|
||||
"app": app_name
|
||||
}
|
||||
self._subject = account_validity_renew_email_subject % {"app": app_name}
|
||||
|
||||
self._from_string = self.hs.config.email_notif_from % {"app": app_name}
|
||||
self._from_string = hs.config.email_notif_from % {"app": app_name}
|
||||
except Exception:
|
||||
# If substitution failed, fall back to the bare strings.
|
||||
self._subject = self._account_validity.renew_email_subject
|
||||
self._from_string = self.hs.config.email_notif_from
|
||||
self._subject = account_validity_renew_email_subject
|
||||
self._from_string = hs.config.email_notif_from
|
||||
|
||||
self._raw_from = email.utils.parseaddr(self._from_string)[1]
|
||||
|
||||
|
@ -220,50 +236,87 @@ class AccountValidityHandler:
|
|||
attempts += 1
|
||||
raise StoreError(500, "Couldn't generate a unique string as refresh string.")
|
||||
|
||||
async def renew_account(self, renewal_token: str) -> bool:
|
||||
async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]:
|
||||
"""Renews the account attached to a given renewal token by pushing back the
|
||||
expiration date by the current validity period in the server's configuration.
|
||||
|
||||
If it turns out that the token is valid but has already been used, then the
|
||||
token is considered stale. A token is stale if the 'token_used_ts_ms' db column
|
||||
is non-null.
|
||||
|
||||
Args:
|
||||
renewal_token: Token sent with the renewal request.
|
||||
Returns:
|
||||
Whether the provided token is valid.
|
||||
A tuple containing:
|
||||
* A bool representing whether the token is valid and unused.
|
||||
* A bool which is `True` if the token is valid, but stale.
|
||||
* An int representing the user's expiry timestamp as milliseconds since the
|
||||
epoch, or 0 if the token was invalid.
|
||||
"""
|
||||
try:
|
||||
user_id = await self.store.get_user_from_renewal_token(renewal_token)
|
||||
(
|
||||
user_id,
|
||||
current_expiration_ts,
|
||||
token_used_ts,
|
||||
) = await self.store.get_user_from_renewal_token(renewal_token)
|
||||
except StoreError:
|
||||
return False
|
||||
return False, False, 0
|
||||
|
||||
# Check whether this token has already been used.
|
||||
if token_used_ts:
|
||||
logger.info(
|
||||
"User '%s' attempted to use previously used token '%s' to renew account",
|
||||
user_id,
|
||||
renewal_token,
|
||||
)
|
||||
return False, True, current_expiration_ts
|
||||
|
||||
logger.debug("Renewing an account for user %s", user_id)
|
||||
await self.renew_account_for_user(user_id)
|
||||
|
||||
return True
|
||||
# Renew the account. Pass the renewal_token here so that it is not cleared.
|
||||
# We want to keep the token around in case the user attempts to renew their
|
||||
# account with the same token twice (clicking the email link twice).
|
||||
#
|
||||
# In that case, the token will be accepted, but the account's expiration ts
|
||||
# will remain unchanged.
|
||||
new_expiration_ts = await self.renew_account_for_user(
|
||||
user_id, renewal_token=renewal_token
|
||||
)
|
||||
|
||||
return True, False, new_expiration_ts
|
||||
|
||||
async def renew_account_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
expiration_ts: Optional[int] = None,
|
||||
email_sent: bool = False,
|
||||
renewal_token: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Renews the account attached to a given user by pushing back the
|
||||
expiration date by the current validity period in the server's
|
||||
configuration.
|
||||
|
||||
Args:
|
||||
renewal_token: Token sent with the renewal request.
|
||||
user_id: The ID of the user to renew.
|
||||
expiration_ts: New expiration date. Defaults to now + validity period.
|
||||
email_sen: Whether an email has been sent for this validity period.
|
||||
Defaults to False.
|
||||
email_sent: Whether an email has been sent for this validity period.
|
||||
renewal_token: Token sent with the renewal request. The user's token
|
||||
will be cleared if this is None.
|
||||
|
||||
Returns:
|
||||
New expiration date for this account, as a timestamp in
|
||||
milliseconds since epoch.
|
||||
"""
|
||||
now = self.clock.time_msec()
|
||||
if expiration_ts is None:
|
||||
expiration_ts = self.clock.time_msec() + self._account_validity.period
|
||||
expiration_ts = now + self._account_validity_period
|
||||
|
||||
await self.store.set_account_validity_for_user(
|
||||
user_id=user_id, expiration_ts=expiration_ts, email_sent=email_sent
|
||||
user_id=user_id,
|
||||
expiration_ts=expiration_ts,
|
||||
email_sent=email_sent,
|
||||
renewal_token=renewal_token,
|
||||
token_used_ts=now,
|
||||
)
|
||||
|
||||
return expiration_ts
|
||||
|
|
|
@ -49,7 +49,9 @@ class DeactivateAccountHandler(BaseHandler):
|
|||
if hs.config.run_background_tasks:
|
||||
hs.get_reactor().callWhenRunning(self._start_user_parting)
|
||||
|
||||
self._account_validity_enabled = hs.config.account_validity.enabled
|
||||
self._account_validity_enabled = (
|
||||
hs.config.account_validity.account_validity_enabled
|
||||
)
|
||||
|
||||
async def deactivate_account(
|
||||
self,
|
||||
|
|
|
@ -62,7 +62,9 @@ class PusherPool:
|
|||
self.store = self.hs.get_datastore()
|
||||
self.clock = self.hs.get_clock()
|
||||
|
||||
self._account_validity = hs.config.account_validity
|
||||
self._account_validity_enabled = (
|
||||
hs.config.account_validity.account_validity_enabled
|
||||
)
|
||||
|
||||
# We shard the handling of push notifications by user ID.
|
||||
self._pusher_shard_config = hs.config.push.pusher_shard_config
|
||||
|
@ -236,7 +238,7 @@ class PusherPool:
|
|||
|
||||
for u in users_affected:
|
||||
# Don't push if the user account has expired
|
||||
if self._account_validity.enabled:
|
||||
if self._account_validity_enabled:
|
||||
expired = await self.store.is_account_expired(
|
||||
u, self.clock.time_msec()
|
||||
)
|
||||
|
@ -266,7 +268,7 @@ class PusherPool:
|
|||
|
||||
for u in users_affected:
|
||||
# Don't push if the user account has expired
|
||||
if self._account_validity.enabled:
|
||||
if self._account_validity_enabled:
|
||||
expired = await self.store.is_account_expired(
|
||||
u, self.clock.time_msec()
|
||||
)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<html><body>Your account is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.</body><html>
|
|
@ -1 +1 @@
|
|||
<html><body>Your account has been successfully renewed.</body><html>
|
||||
<html><body>Your account has been successfully renewed and is valid until {{ expiration_ts|format_ts("%d-%m-%Y") }}.</body><html>
|
||||
|
|
|
@ -36,24 +36,40 @@ class AccountValidityRenewServlet(RestServlet):
|
|||
self.hs = hs
|
||||
self.account_activity_handler = hs.get_account_validity_handler()
|
||||
self.auth = hs.get_auth()
|
||||
self.success_html = hs.config.account_validity.account_renewed_html_content
|
||||
self.failure_html = hs.config.account_validity.invalid_token_html_content
|
||||
self.account_renewed_template = (
|
||||
hs.config.account_validity.account_validity_account_renewed_template
|
||||
)
|
||||
self.account_previously_renewed_template = (
|
||||
hs.config.account_validity.account_validity_account_previously_renewed_template
|
||||
)
|
||||
self.invalid_token_template = (
|
||||
hs.config.account_validity.account_validity_invalid_token_template
|
||||
)
|
||||
|
||||
async def on_GET(self, request):
|
||||
if b"token" not in request.args:
|
||||
raise SynapseError(400, "Missing renewal token")
|
||||
renewal_token = request.args[b"token"][0]
|
||||
|
||||
token_valid = await self.account_activity_handler.renew_account(
|
||||
(
|
||||
token_valid,
|
||||
token_stale,
|
||||
expiration_ts,
|
||||
) = await self.account_activity_handler.renew_account(
|
||||
renewal_token.decode("utf8")
|
||||
)
|
||||
|
||||
if token_valid:
|
||||
status_code = 200
|
||||
response = self.success_html
|
||||
response = self.account_renewed_template.render(expiration_ts=expiration_ts)
|
||||
elif token_stale:
|
||||
status_code = 200
|
||||
response = self.account_previously_renewed_template.render(
|
||||
expiration_ts=expiration_ts
|
||||
)
|
||||
else:
|
||||
status_code = 404
|
||||
response = self.failure_html
|
||||
response = self.invalid_token_template.render(expiration_ts=expiration_ts)
|
||||
|
||||
respond_with_html(request, status_code, response)
|
||||
|
||||
|
@ -71,10 +87,12 @@ class AccountValiditySendMailServlet(RestServlet):
|
|||
self.hs = hs
|
||||
self.account_activity_handler = hs.get_account_validity_handler()
|
||||
self.auth = hs.get_auth()
|
||||
self.account_validity = self.hs.config.account_validity
|
||||
self.account_validity_renew_by_email_enabled = (
|
||||
hs.config.account_validity.account_validity_renew_by_email_enabled
|
||||
)
|
||||
|
||||
async def on_POST(self, request):
|
||||
if not self.account_validity.renew_by_email_enabled:
|
||||
if not self.account_validity_renew_by_email_enabled:
|
||||
raise AuthError(
|
||||
403, "Account renewal via email is disabled on this server."
|
||||
)
|
||||
|
|
|
@ -91,8 +91,20 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
id_column=None,
|
||||
)
|
||||
|
||||
self._account_validity = hs.config.account_validity
|
||||
if hs.config.run_background_tasks and self._account_validity.enabled:
|
||||
self._account_validity_enabled = (
|
||||
hs.config.account_validity.account_validity_enabled
|
||||
)
|
||||
self._account_validity_period = None
|
||||
self._account_validity_startup_job_max_delta = None
|
||||
if self._account_validity_enabled:
|
||||
self._account_validity_period = (
|
||||
hs.config.account_validity.account_validity_period
|
||||
)
|
||||
self._account_validity_startup_job_max_delta = (
|
||||
hs.config.account_validity.account_validity_startup_job_max_delta
|
||||
)
|
||||
|
||||
if hs.config.run_background_tasks:
|
||||
self._clock.call_later(
|
||||
0.0,
|
||||
self._set_expiration_date_when_missing,
|
||||
|
@ -194,6 +206,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
expiration_ts: int,
|
||||
email_sent: bool,
|
||||
renewal_token: Optional[str] = None,
|
||||
token_used_ts: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Updates the account validity properties of the given account, with the
|
||||
given values.
|
||||
|
@ -207,6 +220,8 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
period.
|
||||
renewal_token: Renewal token the user can use to extend the validity
|
||||
of their account. Defaults to no token.
|
||||
token_used_ts: A timestamp of when the current token was used to renew
|
||||
the account.
|
||||
"""
|
||||
|
||||
def set_account_validity_for_user_txn(txn):
|
||||
|
@ -218,6 +233,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
"expiration_ts_ms": expiration_ts,
|
||||
"email_sent": email_sent,
|
||||
"renewal_token": renewal_token,
|
||||
"token_used_ts_ms": token_used_ts,
|
||||
},
|
||||
)
|
||||
self._invalidate_cache_and_stream(
|
||||
|
@ -231,7 +247,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
async def set_renewal_token_for_user(
|
||||
self, user_id: str, renewal_token: str
|
||||
) -> None:
|
||||
"""Defines a renewal token for a given user.
|
||||
"""Defines a renewal token for a given user, and clears the token_used timestamp.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user to set the renewal token for.
|
||||
|
@ -244,26 +260,40 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
await self.db_pool.simple_update_one(
|
||||
table="account_validity",
|
||||
keyvalues={"user_id": user_id},
|
||||
updatevalues={"renewal_token": renewal_token},
|
||||
updatevalues={"renewal_token": renewal_token, "token_used_ts_ms": None},
|
||||
desc="set_renewal_token_for_user",
|
||||
)
|
||||
|
||||
async def get_user_from_renewal_token(self, renewal_token: str) -> str:
|
||||
"""Get a user ID from a renewal token.
|
||||
async def get_user_from_renewal_token(
|
||||
self, renewal_token: str
|
||||
) -> Tuple[str, int, Optional[int]]:
|
||||
"""Get a user ID and renewal status from a renewal token.
|
||||
|
||||
Args:
|
||||
renewal_token: The renewal token to perform the lookup with.
|
||||
|
||||
Returns:
|
||||
The ID of the user to which the token belongs.
|
||||
A tuple of containing the following values:
|
||||
* The ID of a user to which the token belongs.
|
||||
* An int representing the user's expiry timestamp as milliseconds since the
|
||||
epoch, or 0 if the token was invalid.
|
||||
* An optional int representing the timestamp of when the user renewed their
|
||||
account timestamp as milliseconds since the epoch. None if the account
|
||||
has not been renewed using the current token yet.
|
||||
"""
|
||||
return await self.db_pool.simple_select_one_onecol(
|
||||
ret_dict = await self.db_pool.simple_select_one(
|
||||
table="account_validity",
|
||||
keyvalues={"renewal_token": renewal_token},
|
||||
retcol="user_id",
|
||||
retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"],
|
||||
desc="get_user_from_renewal_token",
|
||||
)
|
||||
|
||||
return (
|
||||
ret_dict["user_id"],
|
||||
ret_dict["expiration_ts_ms"],
|
||||
ret_dict["token_used_ts_ms"],
|
||||
)
|
||||
|
||||
async def get_renewal_token_for_user(self, user_id: str) -> str:
|
||||
"""Get the renewal token associated with a given user ID.
|
||||
|
||||
|
@ -302,7 +332,7 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
"get_users_expiring_soon",
|
||||
select_users_txn,
|
||||
self._clock.time_msec(),
|
||||
self.config.account_validity.renew_at,
|
||||
self.config.account_validity_renew_at,
|
||||
)
|
||||
|
||||
async def set_renewal_mail_status(self, user_id: str, email_sent: bool) -> None:
|
||||
|
@ -964,11 +994,11 @@ class RegistrationWorkerStore(CacheInvalidationWorkerStore):
|
|||
delta equal to 10% of the validity period.
|
||||
"""
|
||||
now_ms = self._clock.time_msec()
|
||||
expiration_ts = now_ms + self._account_validity.period
|
||||
expiration_ts = now_ms + self._account_validity_period
|
||||
|
||||
if use_delta:
|
||||
expiration_ts = self.rand.randrange(
|
||||
expiration_ts - self._account_validity.startup_job_max_delta,
|
||||
expiration_ts - self._account_validity_startup_job_max_delta,
|
||||
expiration_ts,
|
||||
)
|
||||
|
||||
|
@ -1412,7 +1442,7 @@ class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
|
|||
except self.database_engine.module.IntegrityError:
|
||||
raise StoreError(400, "User ID already taken.", errcode=Codes.USER_IN_USE)
|
||||
|
||||
if self._account_validity.enabled:
|
||||
if self._account_validity_enabled:
|
||||
self.set_expiration_date_for_user_txn(txn, user_id)
|
||||
|
||||
if create_profile_with_displayname:
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
-- Track when users renew their account using the value of the 'renewal_token' column.
|
||||
-- This field should be set to NULL after a fresh token is generated.
|
||||
ALTER TABLE account_validity ADD token_used_ts_ms BIGINT;
|
|
@ -492,8 +492,8 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase):
|
|||
|
||||
(user_id, tok) = self.create_user()
|
||||
|
||||
# Move 6 days forward. This should trigger a renewal email to be sent.
|
||||
self.reactor.advance(datetime.timedelta(days=6).total_seconds())
|
||||
# Move 5 days forward. This should trigger a renewal email to be sent.
|
||||
self.reactor.advance(datetime.timedelta(days=5).total_seconds())
|
||||
self.assertEqual(len(self.email_attempts), 1)
|
||||
|
||||
# Retrieving the URL from the email is too much pain for now, so we
|
||||
|
@ -504,14 +504,32 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase):
|
|||
self.assertEquals(channel.result["code"], b"200", channel.result)
|
||||
|
||||
# Check that we're getting HTML back.
|
||||
content_type = None
|
||||
for header in channel.result.get("headers", []):
|
||||
if header[0] == b"Content-Type":
|
||||
content_type = header[1]
|
||||
self.assertEqual(content_type, b"text/html; charset=utf-8", channel.result)
|
||||
content_type = channel.headers.getRawHeaders(b"Content-Type")
|
||||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result)
|
||||
|
||||
# Check that the HTML we're getting is the one we expect on a successful renewal.
|
||||
expected_html = self.hs.config.account_validity.account_renewed_html_content
|
||||
expiration_ts = self.get_success(self.store.get_expiration_ts_for_user(user_id))
|
||||
expected_html = self.hs.config.account_validity.account_validity_account_renewed_template.render(
|
||||
expiration_ts=expiration_ts
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.result["body"], expected_html.encode("utf8"), channel.result
|
||||
)
|
||||
|
||||
# Move 1 day forward. Try to renew with the same token again.
|
||||
url = "/_matrix/client/unstable/account_validity/renew?token=%s" % renewal_token
|
||||
channel = self.make_request(b"GET", url)
|
||||
self.assertEquals(channel.result["code"], b"200", channel.result)
|
||||
|
||||
# Check that we're getting HTML back.
|
||||
content_type = channel.headers.getRawHeaders(b"Content-Type")
|
||||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result)
|
||||
|
||||
# Check that the HTML we're getting is the one we expect when reusing a
|
||||
# token. The account expiration date should not have changed.
|
||||
expected_html = self.hs.config.account_validity.account_validity_account_previously_renewed_template.render(
|
||||
expiration_ts=expiration_ts
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.result["body"], expected_html.encode("utf8"), channel.result
|
||||
)
|
||||
|
@ -531,15 +549,14 @@ class AccountValidityRenewalByEmailTestCase(unittest.HomeserverTestCase):
|
|||
self.assertEquals(channel.result["code"], b"404", channel.result)
|
||||
|
||||
# Check that we're getting HTML back.
|
||||
content_type = None
|
||||
for header in channel.result.get("headers", []):
|
||||
if header[0] == b"Content-Type":
|
||||
content_type = header[1]
|
||||
self.assertEqual(content_type, b"text/html; charset=utf-8", channel.result)
|
||||
content_type = channel.headers.getRawHeaders(b"Content-Type")
|
||||
self.assertEqual(content_type, [b"text/html; charset=utf-8"], channel.result)
|
||||
|
||||
# Check that the HTML we're getting is the one we expect when using an
|
||||
# invalid/unknown token.
|
||||
expected_html = self.hs.config.account_validity.invalid_token_html_content
|
||||
expected_html = (
|
||||
self.hs.config.account_validity.account_validity_invalid_token_template.render()
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.result["body"], expected_html.encode("utf8"), channel.result
|
||||
)
|
||||
|
@ -647,7 +664,12 @@ class AccountValidityBackgroundJobTestCase(unittest.HomeserverTestCase):
|
|||
config["account_validity"] = {"enabled": False}
|
||||
|
||||
self.hs = self.setup_test_homeserver(config=config)
|
||||
self.hs.config.account_validity.period = self.validity_period
|
||||
|
||||
# We need to set these directly, instead of in the homeserver config dict above.
|
||||
# This is due to account validity-related config options not being read by
|
||||
# Synapse when account_validity.enabled is False.
|
||||
self.hs.get_datastore()._account_validity_period = self.validity_period
|
||||
self.hs.get_datastore()._account_validity_startup_job_max_delta = self.max_delta
|
||||
|
||||
self.store = self.hs.get_datastore()
|
||||
|
||||
|
|
Loading…
Reference in New Issue