Replaced importing the "typing" module as "t" with importing the individual names in the "typing" module. We do not have as many names to import. This is also to be consistent with the practices of most major and standard packages and examples.
This commit is contained in:
parent
264ba158ee
commit
e861cae2e0
@ -20,8 +20,8 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing as t
|
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
|
from typing import Optional, Literal
|
||||||
|
|
||||||
|
|
||||||
def make_password_hash(realm: str, username: str, password: str) -> str:
|
def make_password_hash(realm: str, username: str, password: str) -> str:
|
||||||
@ -44,10 +44,10 @@ def make_password_hash(realm: str, username: str, password: str) -> str:
|
|||||||
|
|
||||||
def calc_response(
|
def calc_response(
|
||||||
method: str, uri: str, password_hash: str,
|
method: str, uri: str, password_hash: str,
|
||||||
nonce: str, qop: t.Optional[t.Literal["auth", "auth-int"]] = None,
|
nonce: str, qop: Optional[Literal["auth", "auth-int"]] = None,
|
||||||
algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = "MD5-sess",
|
algorithm: Optional[Literal["MD5", "MD5-sess"]] = "MD5-sess",
|
||||||
cnonce: t.Optional[str] = None, nc: t.Optional[str] = None,
|
cnonce: Optional[str] = None, nc: Optional[str] = None,
|
||||||
body: t.Optional[bytes] = None) -> str:
|
body: Optional[bytes] = None) -> str:
|
||||||
"""Calculates the response value of the HTTP digest authentication.
|
"""Calculates the response value of the HTTP digest authentication.
|
||||||
|
|
||||||
:param method: The request method.
|
:param method: The request method.
|
||||||
|
@ -23,9 +23,9 @@ See `RFC 2617`_ HTTP Authentication: Basic and Digest Access Authentication
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import typing as t
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from secrets import token_urlsafe, randbits
|
from secrets import token_urlsafe, randbits
|
||||||
|
from typing import Any, Optional, Literal, Callable, List
|
||||||
|
|
||||||
from flask import g, request, Response, session, abort, Flask, Request, \
|
from flask import g, request, Response, session, abort, Flask, Request, \
|
||||||
current_app
|
current_app
|
||||||
@ -38,7 +38,7 @@ from flask_digest_auth.algo import calc_response
|
|||||||
class DigestAuth:
|
class DigestAuth:
|
||||||
"""The HTTP digest authentication."""
|
"""The HTTP digest authentication."""
|
||||||
|
|
||||||
def __init__(self, realm: t.Optional[str] = None):
|
def __init__(self, realm: Optional[str] = None):
|
||||||
"""Constructs the HTTP digest authentication.
|
"""Constructs the HTTP digest authentication.
|
||||||
|
|
||||||
:param realm: The realm.
|
:param realm: The realm.
|
||||||
@ -48,16 +48,15 @@ class DigestAuth:
|
|||||||
"""The serializer to generate and validate the nonce and opaque."""
|
"""The serializer to generate and validate the nonce and opaque."""
|
||||||
self.realm: str = "Login Required" if realm is None else realm
|
self.realm: str = "Login Required" if realm is None else realm
|
||||||
"""The realm. Default is "Login Required"."""
|
"""The realm. Default is "Login Required"."""
|
||||||
self.algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None
|
self.algorithm: Optional[Literal["MD5", "MD5-sess"]] = None
|
||||||
"""The algorithm, either None, ``MD5``, or ``MD5-sess``. Default is
|
"""The algorithm, either None, ``MD5``, or ``MD5-sess``. Default is
|
||||||
None."""
|
None."""
|
||||||
self.use_opaque: bool = True
|
self.use_opaque: bool = True
|
||||||
"""Whether to use an opaque. Default is True."""
|
"""Whether to use an opaque. Default is True."""
|
||||||
self.__domain: t.List[str] = []
|
self.__domain: List[str] = []
|
||||||
"""A list of directories that this username and password applies to.
|
"""A list of directories that this username and password applies to.
|
||||||
Default is empty."""
|
Default is empty."""
|
||||||
self.__qop: t.List[t.Literal["auth", "auth-int"]] \
|
self.__qop: List[Literal["auth", "auth-int"]] = ["auth", "auth-int"]
|
||||||
= ["auth", "auth-int"]
|
|
||||||
"""A list of supported quality of protection supported, either
|
"""A list of supported quality of protection supported, either
|
||||||
``qop``, ``auth-int``, both, or empty. Default is both."""
|
``qop``, ``auth-int``, both, or empty. Default is both."""
|
||||||
self.__get_password_hash: BasePasswordHashGetter \
|
self.__get_password_hash: BasePasswordHashGetter \
|
||||||
@ -68,7 +67,7 @@ class DigestAuth:
|
|||||||
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
|
self.__on_login: BaseOnLogInCallback = BaseOnLogInCallback()
|
||||||
"""The callback to run when the user logs in."""
|
"""The callback to run when the user logs in."""
|
||||||
|
|
||||||
def login_required(self, view) -> t.Callable:
|
def login_required(self, view) -> Callable:
|
||||||
"""The view decorator for the HTTP digest authentication.
|
"""The view decorator for the HTTP digest authentication.
|
||||||
|
|
||||||
:Example:
|
:Example:
|
||||||
@ -89,7 +88,7 @@ class DigestAuth:
|
|||||||
class NoLogInException(Exception):
|
class NoLogInException(Exception):
|
||||||
"""The exception thrown when the user is not authorized."""
|
"""The exception thrown when the user is not authorized."""
|
||||||
|
|
||||||
def get_logged_in_user() -> t.Any:
|
def get_logged_in_user() -> Any:
|
||||||
"""Returns the currently logged-in user.
|
"""Returns the currently logged-in user.
|
||||||
|
|
||||||
:return: The currently logged-in user.
|
:return: The currently logged-in user.
|
||||||
@ -97,13 +96,13 @@ class DigestAuth:
|
|||||||
"""
|
"""
|
||||||
if "user" not in session:
|
if "user" not in session:
|
||||||
raise NoLogInException
|
raise NoLogInException
|
||||||
user: t.Optional[t.Any] = self.__get_user(session["user"])
|
user: Optional[Any] = self.__get_user(session["user"])
|
||||||
if user is None:
|
if user is None:
|
||||||
del session["user"]
|
del session["user"]
|
||||||
raise NoLogInException
|
raise NoLogInException
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def auth_user(state: AuthState) -> t.Any:
|
def auth_user(state: AuthState) -> Any:
|
||||||
"""Authenticates a user.
|
"""Authenticates a user.
|
||||||
|
|
||||||
:param state: The authentication state.
|
:param state: The authentication state.
|
||||||
@ -121,7 +120,7 @@ class DigestAuth:
|
|||||||
return self.__get_user(authorization.username)
|
return self.__get_user(authorization.username)
|
||||||
|
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
def login_required_view(*args, **kwargs) -> t.Any:
|
def login_required_view(*args, **kwargs) -> Any:
|
||||||
"""The login-protected view.
|
"""The login-protected view.
|
||||||
|
|
||||||
:param args: The positional arguments of the view.
|
:param args: The positional arguments of the view.
|
||||||
@ -171,7 +170,7 @@ class DigestAuth:
|
|||||||
except BadData:
|
except BadData:
|
||||||
raise UnauthorizedException("Invalid opaque")
|
raise UnauthorizedException("Invalid opaque")
|
||||||
state.opaque = authorization.opaque
|
state.opaque = authorization.opaque
|
||||||
password_hash: t.Optional[str] \
|
password_hash: Optional[str] \
|
||||||
= self.__get_password_hash(authorization.username)
|
= self.__get_password_hash(authorization.username)
|
||||||
if password_hash is None:
|
if password_hash is None:
|
||||||
raise UnauthorizedException(
|
raise UnauthorizedException(
|
||||||
@ -202,7 +201,7 @@ class DigestAuth:
|
|||||||
:return: The ``WWW-Authenticate`` response header.
|
:return: The ``WWW-Authenticate`` response header.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_opaque() -> t.Optional[str]:
|
def get_opaque() -> Optional[str]:
|
||||||
"""Returns the opaque value.
|
"""Returns the opaque value.
|
||||||
|
|
||||||
:return: The opaque value.
|
:return: The opaque value.
|
||||||
@ -213,7 +212,7 @@ class DigestAuth:
|
|||||||
return state.opaque
|
return state.opaque
|
||||||
return self.__serializer.dumps(randbits(32), salt="opaque")
|
return self.__serializer.dumps(randbits(32), salt="opaque")
|
||||||
|
|
||||||
opaque: t.Optional[str] = get_opaque()
|
opaque: Optional[str] = get_opaque()
|
||||||
nonce: str = self.__serializer.dumps(
|
nonce: str = self.__serializer.dumps(
|
||||||
randbits(32),
|
randbits(32),
|
||||||
salt="nonce" if opaque is None else f"nonce-{opaque}")
|
salt="nonce" if opaque is None else f"nonce-{opaque}")
|
||||||
@ -234,7 +233,7 @@ class DigestAuth:
|
|||||||
header += f", qop=\"{qop_list}\""
|
header += f", qop=\"{qop_list}\""
|
||||||
return header
|
return header
|
||||||
|
|
||||||
def register_get_password(self, func: t.Callable[[str], t.Optional[str]])\
|
def register_get_password(self, func: Callable[[str], Optional[str]]) \
|
||||||
-> None:
|
-> None:
|
||||||
"""The decorator to register the callback to obtain the password hash.
|
"""The decorator to register the callback to obtain the password hash.
|
||||||
|
|
||||||
@ -256,7 +255,7 @@ class DigestAuth:
|
|||||||
"""The base password hash getter."""
|
"""The base password hash getter."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(username: str) -> t.Optional[str]:
|
def __call__(username: str) -> Optional[str]:
|
||||||
"""Returns the password hash of a user.
|
"""Returns the password hash of a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -266,8 +265,7 @@ class DigestAuth:
|
|||||||
|
|
||||||
self.__get_password_hash = PasswordHashGetter()
|
self.__get_password_hash = PasswordHashGetter()
|
||||||
|
|
||||||
def register_get_user(self, func: t.Callable[[str], t.Optional[t.Any]])\
|
def register_get_user(self, func: Callable[[str], Optional[Any]]) -> None:
|
||||||
-> None:
|
|
||||||
"""The decorator to register the callback to obtain the user.
|
"""The decorator to register the callback to obtain the user.
|
||||||
|
|
||||||
:Example:
|
:Example:
|
||||||
@ -287,7 +285,7 @@ class DigestAuth:
|
|||||||
"""The user getter."""
|
"""The user getter."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(username: str) -> t.Optional[t.Any]:
|
def __call__(username: str) -> Optional[Any]:
|
||||||
"""Returns a user.
|
"""Returns a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -297,7 +295,7 @@ class DigestAuth:
|
|||||||
|
|
||||||
self.__get_user = UserGetter()
|
self.__get_user = UserGetter()
|
||||||
|
|
||||||
def register_on_login(self, func: t.Callable[[t.Any], None]) -> None:
|
def register_on_login(self, func: Callable[[Any], None]) -> None:
|
||||||
"""The decorator to register the callback to run when the user logs in.
|
"""The decorator to register the callback to run when the user logs in.
|
||||||
|
|
||||||
:Example:
|
:Example:
|
||||||
@ -316,7 +314,7 @@ class DigestAuth:
|
|||||||
"""The callback when the user logs in."""
|
"""The callback when the user logs in."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(user: t.Any) -> None:
|
def __call__(user: Any) -> None:
|
||||||
"""Runs the callback when the user logs in.
|
"""Runs the callback when the user logs in.
|
||||||
|
|
||||||
:param user: The logged-in user.
|
:param user: The logged-in user.
|
||||||
@ -366,7 +364,7 @@ class DigestAuth:
|
|||||||
abort(response)
|
abort(response)
|
||||||
|
|
||||||
@login_manager.request_loader
|
@login_manager.request_loader
|
||||||
def load_user_from_request(req: Request) -> t.Optional[t.Any]:
|
def load_user_from_request(req: Request) -> Optional[Any]:
|
||||||
"""Loads the user from the request header.
|
"""Loads the user from the request header.
|
||||||
|
|
||||||
:param req: The request.
|
:param req: The request.
|
||||||
@ -427,9 +425,9 @@ class AuthState:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs the authorization state."""
|
"""Constructs the authorization state."""
|
||||||
self.opaque: t.Optional[str] = None
|
self.opaque: Optional[str] = None
|
||||||
"""The opaque value specified by the client, if valid."""
|
"""The opaque value specified by the client, if valid."""
|
||||||
self.stale: t.Optional[bool] = None
|
self.stale: Optional[bool] = None
|
||||||
"""The stale value, if there is a previous log in attempt."""
|
"""The stale value, if there is a previous log in attempt."""
|
||||||
|
|
||||||
|
|
||||||
@ -446,7 +444,7 @@ class BasePasswordHashGetter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(username: str) -> t.Optional[str]:
|
def __call__(username: str) -> Optional[str]:
|
||||||
"""Returns the password hash of a user.
|
"""Returns the password hash of a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -467,7 +465,7 @@ class BaseUserGetter:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(username: str) -> t.Optional[t.Any]:
|
def __call__(username: str) -> Optional[Any]:
|
||||||
"""Returns a user.
|
"""Returns a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -487,7 +485,7 @@ class BaseOnLogInCallback:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __call__(user: t.Any) -> None:
|
def __call__(user: Any) -> None:
|
||||||
"""Runs the callback when the user logs in.
|
"""Runs the callback when the user logs in.
|
||||||
|
|
||||||
:param user: The logged-in user.
|
:param user: The logged-in user.
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
"""The test client with HTTP digest authentication enabled.
|
"""The test client with HTTP digest authentication enabled.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
|
from typing import Optional, Literal, Tuple, Dict
|
||||||
|
|
||||||
from flask import g
|
from flask import g
|
||||||
from werkzeug.datastructures import Authorization, WWWAuthenticate
|
from werkzeug.datastructures import Authorization, WWWAuthenticate
|
||||||
@ -83,7 +83,7 @@ class Client(WerkzeugClient):
|
|||||||
.. _pytest: https://pytest.org
|
.. _pytest: https://pytest.org
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def open(self, *args, digest_auth: t.Optional[t.Tuple[str, str]] = None,
|
def open(self, *args, digest_auth: Optional[Tuple[str, str]] = None,
|
||||||
**kwargs) -> TestResponse:
|
**kwargs) -> TestResponse:
|
||||||
"""Opens a request.
|
"""Opens a request.
|
||||||
|
|
||||||
@ -117,14 +117,14 @@ class Client(WerkzeugClient):
|
|||||||
:param password: The password.
|
:param password: The password.
|
||||||
:return: The request authorization.
|
:return: The request authorization.
|
||||||
"""
|
"""
|
||||||
qop: t.Optional[t.Literal["auth", "auth-int"]] = None
|
qop: Optional[Literal["auth", "auth-int"]] = None
|
||||||
if www_authenticate.qop is not None and "auth" in www_authenticate.qop:
|
if www_authenticate.qop is not None and "auth" in www_authenticate.qop:
|
||||||
qop = "auth"
|
qop = "auth"
|
||||||
|
|
||||||
cnonce: t.Optional[str] = None
|
cnonce: Optional[str] = None
|
||||||
if qop is not None or www_authenticate.algorithm == "MD5-sess":
|
if qop is not None or www_authenticate.algorithm == "MD5-sess":
|
||||||
cnonce = token_urlsafe(8)
|
cnonce = token_urlsafe(8)
|
||||||
nc: t.Optional[str] = None
|
nc: Optional[str] = None
|
||||||
count: int = 1
|
count: int = 1
|
||||||
if qop is not None:
|
if qop is not None:
|
||||||
nc: str = hex(count)[2:].zfill(8)
|
nc: str = hex(count)[2:].zfill(8)
|
||||||
@ -137,7 +137,7 @@ class Client(WerkzeugClient):
|
|||||||
algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc,
|
algorithm=www_authenticate.algorithm, cnonce=cnonce, nc=nc,
|
||||||
body=None)
|
body=None)
|
||||||
|
|
||||||
data: t.Dict[str, str] = {
|
data: Dict[str, str] = {
|
||||||
"username": username, "realm": www_authenticate.realm,
|
"username": username, "realm": www_authenticate.realm,
|
||||||
"nonce": www_authenticate.nonce, "uri": uri, "response": expected}
|
"nonce": www_authenticate.nonce, "uri": uri, "response": expected}
|
||||||
if www_authenticate.algorithm is not None:
|
if www_authenticate.algorithm is not None:
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
"""The test case for the HTTP digest authentication algorithm.
|
"""The test case for the HTTP digest authentication algorithm.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from flask_digest_auth import make_password_hash, calc_response
|
from flask_digest_auth import make_password_hash, calc_response
|
||||||
|
|
||||||
@ -39,11 +39,11 @@ class AlgorithmTestCase(unittest.TestCase):
|
|||||||
method: str = "GET"
|
method: str = "GET"
|
||||||
uri: str = "/dir/index.html"
|
uri: str = "/dir/index.html"
|
||||||
nonce: str = "dcd98b7102dd2f0e8b11d0f600bfb0c093"
|
nonce: str = "dcd98b7102dd2f0e8b11d0f600bfb0c093"
|
||||||
qop: t.Optional[t.Literal["auth", "auth-int"]] = "auth"
|
qop: Optional[Literal["auth", "auth-int"]] = "auth"
|
||||||
algorithm: t.Optional[t.Literal["MD5", "MD5-sess"]] = None
|
algorithm: Optional[Literal["MD5", "MD5-sess"]] = None
|
||||||
cnonce: t.Optional[str] = "0a4f113b"
|
cnonce: Optional[str] = "0a4f113b"
|
||||||
nc: t.Optional[str] = "00000001"
|
nc: Optional[str] = "00000001"
|
||||||
body: t.Optional[bytes] = None
|
body: Optional[bytes] = None
|
||||||
|
|
||||||
password_hash: str = make_password_hash(realm, username, password)
|
password_hash: str = make_password_hash(realm, username, password)
|
||||||
response: str = calc_response(method, uri, password_hash, nonce, qop,
|
response: str = calc_response(method, uri, password_hash, nonce, qop,
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
"""The test case for the HTTP digest authentication.
|
"""The test case for the HTTP digest authentication.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
|
from typing import Any, Optional, Dict
|
||||||
|
|
||||||
from flask import Response, Flask, g, redirect, request
|
from flask import Response, Flask, g, redirect, request
|
||||||
from flask_testing import TestCase
|
from flask_testing import TestCase
|
||||||
@ -66,10 +66,10 @@ class AuthenticationTestCase(TestCase):
|
|||||||
auth: DigestAuth = DigestAuth()
|
auth: DigestAuth = DigestAuth()
|
||||||
auth.init_app(app)
|
auth.init_app(app)
|
||||||
self.user: User = User(_USERNAME, _PASSWORD)
|
self.user: User = User(_USERNAME, _PASSWORD)
|
||||||
user_db: t.Dict[str, User] = {_USERNAME: self.user}
|
user_db: Dict[str, User] = {_USERNAME: self.user}
|
||||||
|
|
||||||
@auth.register_get_password
|
@auth.register_get_password
|
||||||
def get_password_hash(username: str) -> t.Optional[str]:
|
def get_password_hash(username: str) -> Optional[str]:
|
||||||
"""Returns the password hash of a user.
|
"""Returns the password hash of a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -79,7 +79,7 @@ class AuthenticationTestCase(TestCase):
|
|||||||
else None
|
else None
|
||||||
|
|
||||||
@auth.register_get_user
|
@auth.register_get_user
|
||||||
def get_user(username: str) -> t.Optional[t.Any]:
|
def get_user(username: str) -> Optional[Any]:
|
||||||
"""Returns a user.
|
"""Returns a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
"""The test case for the Flask-Login integration.
|
"""The test case for the Flask-Login integration.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from flask import Response, Flask, g, redirect, request
|
from flask import Response, Flask, g, redirect, request
|
||||||
from flask_testing import TestCase
|
from flask_testing import TestCase
|
||||||
@ -97,10 +97,10 @@ class FlaskLoginTestCase(TestCase):
|
|||||||
auth.init_app(app)
|
auth.init_app(app)
|
||||||
|
|
||||||
self.user: User = User(_USERNAME, _PASSWORD)
|
self.user: User = User(_USERNAME, _PASSWORD)
|
||||||
user_db: t.Dict[str, User] = {_USERNAME: self.user}
|
user_db: Dict[str, User] = {_USERNAME: self.user}
|
||||||
|
|
||||||
@auth.register_get_password
|
@auth.register_get_password
|
||||||
def get_password_hash(username: str) -> t.Optional[str]:
|
def get_password_hash(username: str) -> Optional[str]:
|
||||||
"""Returns the password hash of a user.
|
"""Returns the password hash of a user.
|
||||||
|
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
@ -119,7 +119,7 @@ class FlaskLoginTestCase(TestCase):
|
|||||||
user.visits = user.visits + 1
|
user.visits = user.visits + 1
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id: str) -> t.Optional[User]:
|
def load_user(user_id: str) -> Optional[User]:
|
||||||
"""Loads a user.
|
"""Loads a user.
|
||||||
|
|
||||||
:param user_id: The username.
|
:param user_id: The username.
|
||||||
|
Loading…
Reference in New Issue
Block a user