Merge branch 'master' into asgi-refactor-attempt
This commit is contained in:
commit
b2d4132a14
|
@ -1,75 +1 @@
|
||||||
# Extensions
|
Moved to the [`awesome-sanic`](https://github.com/mekicha/awesome-sanic) list.
|
||||||
|
|
||||||
A list of Sanic extensions created by the community.
|
|
||||||
|
|
||||||
|
|
||||||
## Extension and Plugin Development
|
|
||||||
|
|
||||||
- [Sanic-Plugins-Framework](https://github.com/ashleysommer/sanicpluginsframework): Library for easily creating and using Sanic plugins.
|
|
||||||
- [sanic-script](https://github.com/tim2anna/sanic-script): An extension for Sanic that adds support for writing commands to your application.
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- [Sanic JWT](https://github.com/ahopkins/sanic-jwt): Authentication, JWT, and permission scoping for Sanic.
|
|
||||||
- [Secure](https://github.com/cakinney/secure): Secure 🔒 is a lightweight package that adds optional security headers and cookie attributes for Python web frameworks.
|
|
||||||
- [Sessions](https://github.com/subyraman/sanic_session): Support for sessions. Allows using redis, memcache or an in memory store.
|
|
||||||
- [CORS](https://github.com/ashleysommer/sanic-cors): A port of flask-cors.
|
|
||||||
- [Sanic-JWT-Extended](https://github.com/devArtoria/Sanic-JWT-Extended): Provides extended JWT support for
|
|
||||||
- [UserAgent](https://github.com/lixxu/sanic-useragent): Add `user_agent` to request
|
|
||||||
- [Limiter](https://github.com/bohea/sanic-limiter): Rate limiting for sanic.
|
|
||||||
- [sanic-oauth](https://gitlab.com/SirEdvin/sanic-oauth): OAuth Library with many provider and OAuth1/OAuth2 support.
|
|
||||||
- [Sanic-Auth](https://github.com/pyx/sanic-auth): A minimal backend agnostic session-based user authentication mechanism for Sanic.
|
|
||||||
- [Sanic-CookieSession](https://github.com/pyx/sanic-cookiesession): A client-side only, cookie-based session, similar to the built-in session in Flask.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [OpenAPI/Swagger](https://github.com/channelcat/sanic-openapi): OpenAPI support, plus a Swagger UI.
|
|
||||||
- [Sanic-RestPlus](https://github.com/ashleysommer/sanic-restplus): A port of Flask-RestPlus for Sanic. Full-featured REST API with SwaggerUI generation.
|
|
||||||
- [sanic-transmute](https://github.com/yunstanford/sanic-transmute): A Sanic extension that generates APIs from python function and classes, and also generates Swagger UI/documentation automatically.
|
|
||||||
|
|
||||||
## ORM and Database Integration
|
|
||||||
|
|
||||||
- [Motor](https://github.com/lixxu/sanic-motor): Simple motor wrapper.
|
|
||||||
- [Sanic CRUD](https://github.com/Typhon66/sanic_crud): CRUD REST API generation with peewee models.
|
|
||||||
- [sanic-graphql](https://github.com/graphql-python/sanic-graphql): GraphQL integration with Sanic
|
|
||||||
- [GINO](https://github.com/fantix/gino): An asyncio ORM on top of SQLAlchemy core, delivered with a Sanic extension. ([Documentation](https://python-gino.readthedocs.io/))
|
|
||||||
- [Databases](https://github.com/encode/databases): Async database access for SQLAlchemy core, with support for PostgreSQL, MySQL, and SQLite.
|
|
||||||
|
|
||||||
## Unit Testing
|
|
||||||
|
|
||||||
- [pytest-sanic](https://github.com/yunstanford/pytest-sanic): A pytest plugin for Sanic. It helps you to test your code asynchronously.
|
|
||||||
|
|
||||||
## Project Creation Template
|
|
||||||
|
|
||||||
- [cookiecutter-sanic](https://github.com/harshanarayana/cookiecutter-sanic): Get your sanic application up and running in a matter of second in a well defined project structure.
|
|
||||||
Batteries included for deployment, unit testing, automated release management and changelog generation.
|
|
||||||
|
|
||||||
## Templating
|
|
||||||
|
|
||||||
- [Sanic-WTF](https://github.com/pyx/sanic-wtf): Sanic-WTF makes using WTForms with Sanic and CSRF (Cross-Site Request Forgery) protection a little bit easier.
|
|
||||||
- [Jinja2](https://github.com/lixxu/sanic-jinja2): Support for Jinja2 template.
|
|
||||||
- [jinja2-sanic](https://github.com/yunstanford/jinja2-sanic): a jinja2 template renderer for Sanic.([Documentation](http://jinja2-sanic.readthedocs.io/en/latest/))
|
|
||||||
|
|
||||||
## API Helper Utilities
|
|
||||||
|
|
||||||
- [sanic-sse](https://github.com/inn0kenty/sanic_sse): [Server-Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) implementation for Sanic.
|
|
||||||
- [Compress](https://github.com/subyraman/sanic_compress): Allows you to easily gzip Sanic responses. A port of Flask-Compress.
|
|
||||||
- [Pagination](https://github.com/lixxu/python-paginate): Simple pagination support.
|
|
||||||
- [Sanic EnvConfig](https://github.com/jamesstidard/sanic-envconfig): Pull environment variables into your sanic config.
|
|
||||||
|
|
||||||
## i18n/l10n Support
|
|
||||||
- [Babel](https://github.com/lixxu/sanic-babel): Adds i18n/l10n support to Sanic applications with the help of the `Babel` library
|
|
||||||
|
|
||||||
## Custom Middlewares
|
|
||||||
|
|
||||||
- [Dispatch](https://github.com/ashleysommer/sanic-dispatcher): A dispatcher inspired by `DispatcherMiddleware` in werkzeug. Can act as a Sanic-to-WSGI adapter.
|
|
||||||
|
|
||||||
## Monitoring and Reporting
|
|
||||||
|
|
||||||
- [sanic-prometheus](https://github.com/dkruchinin/sanic-prometheus): Prometheus metrics for Sanic
|
|
||||||
- [sanic-zipkin](https://github.com/kevinqqnj/sanic-zipkin): Easily report request/function/RPC traces to zipkin/jaeger, through aiozipkin.
|
|
||||||
|
|
||||||
|
|
||||||
## Sample Applications
|
|
||||||
|
|
||||||
- [Sanic-nginx-docker-example](https://github.com/itielshwartz/sanic-nginx-docker-example): Simple and easy to use example of Sanic behined nginx using docker-compose.
|
|
|
@ -193,7 +193,7 @@ The output will be:
|
||||||
{
|
{
|
||||||
"parsed": true,
|
"parsed": true,
|
||||||
"url": "http:\/\/0.0.0.0:8000\/query_string?test1=value1&test2=&test3=value3",
|
"url": "http:\/\/0.0.0.0:8000\/query_string?test1=value1&test2=&test3=value3",
|
||||||
"args_with_blank_values": {"test1": ["value1""], "test2": "", "test3": ["value3"]},
|
"args_with_blank_values": {"test1": ["value1"], "test2": "", "test3": ["value3"]},
|
||||||
"query_string": "test1=value1&test2=&test3=value3"
|
"query_string": "test1=value1&test2=&test3=value3"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
@ -241,6 +241,45 @@ def handler(request):
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The behavior of how the `strict_slashes` flag follows a defined hierarchy which decides if a specific route
|
||||||
|
falls under the `strict_slashes` behavior.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|___ Route
|
||||||
|
|___ Blueprint
|
||||||
|
|___ Application
|
||||||
|
```
|
||||||
|
|
||||||
|
Above hierarchy defines how the `strict_slashes` flag will behave. The first non `None` value of the `strict_slashes`
|
||||||
|
found in the above order will be applied to the route in question.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sanic import Sanic, Blueprint
|
||||||
|
from sanic.response import text
|
||||||
|
|
||||||
|
app = Sanic("sample_strict_slashes", strict_slashes=True)
|
||||||
|
|
||||||
|
@app.get("/r1")
|
||||||
|
def r1(request):
|
||||||
|
return text("strict_slashes is applicable from App level")
|
||||||
|
|
||||||
|
@app.get("/r2", strict_slashes=False)
|
||||||
|
def r2(request):
|
||||||
|
return text("strict_slashes is not applicable due to False value set in route level")
|
||||||
|
|
||||||
|
bp = Blueprint("bp", strict_slashes=False)
|
||||||
|
|
||||||
|
@bp.get("/r3", strict_slashes=True)
|
||||||
|
def r3(request):
|
||||||
|
return text("strict_slashes applicable from blueprint route level")
|
||||||
|
|
||||||
|
bp1 = Blueprint("bp1", strict_slashes=True)
|
||||||
|
|
||||||
|
@bp.get("/r4")
|
||||||
|
def r3(request):
|
||||||
|
return text("strict_slashes applicable from blueprint level")
|
||||||
|
```
|
||||||
|
|
||||||
## User defined route name
|
## User defined route name
|
||||||
|
|
||||||
A custom route name can be used by passing a `name` argument while registering the route which will
|
A custom route name can be used by passing a `name` argument while registering the route which will
|
||||||
|
|
|
@ -37,7 +37,7 @@ class Blueprint:
|
||||||
url_prefix=None,
|
url_prefix=None,
|
||||||
host=None,
|
host=None,
|
||||||
version=None,
|
version=None,
|
||||||
strict_slashes=False,
|
strict_slashes=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
In *Sanic* terminology, a **Blueprint** is a logical collection of
|
In *Sanic* terminology, a **Blueprint** is a logical collection of
|
||||||
|
|
|
@ -218,6 +218,11 @@ class ContentRangeError(SanicException):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@add_status_code(417)
|
||||||
|
class HeaderExpectationFailed(SanicException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@add_status_code(403)
|
@add_status_code(403)
|
||||||
class Forbidden(SanicException):
|
class Forbidden(SanicException):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -29,7 +29,7 @@ except ImportError:
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
|
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
|
||||||
|
EXPECT_HEADER = "EXPECT"
|
||||||
|
|
||||||
# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1
|
# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1
|
||||||
# > If the media type remains unknown, the recipient SHOULD treat it
|
# > If the media type remains unknown, the recipient SHOULD treat it
|
||||||
|
|
|
@ -15,6 +15,7 @@ from httptools.parser.errors import HttpParserError
|
||||||
from multidict import CIMultiDict
|
from multidict import CIMultiDict
|
||||||
|
|
||||||
from sanic.exceptions import (
|
from sanic.exceptions import (
|
||||||
|
HeaderExpectationFailed,
|
||||||
InvalidUsage,
|
InvalidUsage,
|
||||||
PayloadTooLarge,
|
PayloadTooLarge,
|
||||||
RequestTimeout,
|
RequestTimeout,
|
||||||
|
@ -22,7 +23,7 @@ from sanic.exceptions import (
|
||||||
ServiceUnavailable,
|
ServiceUnavailable,
|
||||||
)
|
)
|
||||||
from sanic.log import access_logger, logger
|
from sanic.log import access_logger, logger
|
||||||
from sanic.request import Request, StreamBuffer
|
from sanic.request import EXPECT_HEADER, Request, StreamBuffer
|
||||||
from sanic.response import HTTPResponse
|
from sanic.response import HTTPResponse
|
||||||
|
|
||||||
|
|
||||||
|
@ -314,6 +315,10 @@ class HttpProtocol(asyncio.Protocol):
|
||||||
if self._keep_alive_timeout_handler:
|
if self._keep_alive_timeout_handler:
|
||||||
self._keep_alive_timeout_handler.cancel()
|
self._keep_alive_timeout_handler.cancel()
|
||||||
self._keep_alive_timeout_handler = None
|
self._keep_alive_timeout_handler = None
|
||||||
|
|
||||||
|
if self.request.headers.get(EXPECT_HEADER):
|
||||||
|
self.expect_handler()
|
||||||
|
|
||||||
if self.is_request_stream:
|
if self.is_request_stream:
|
||||||
self._is_stream_handler = self.router.is_stream_handler(
|
self._is_stream_handler = self.router.is_stream_handler(
|
||||||
self.request
|
self.request
|
||||||
|
@ -324,6 +329,21 @@ class HttpProtocol(asyncio.Protocol):
|
||||||
)
|
)
|
||||||
self.execute_request_handler()
|
self.execute_request_handler()
|
||||||
|
|
||||||
|
def expect_handler(self):
|
||||||
|
"""
|
||||||
|
Handler for Expect Header.
|
||||||
|
"""
|
||||||
|
expect = self.request.headers.get(EXPECT_HEADER)
|
||||||
|
if self.request.version == "1.1":
|
||||||
|
if expect.lower() == "100-continue":
|
||||||
|
self.transport.write(b"HTTP/1.1 100 Continue\r\n\r\n")
|
||||||
|
else:
|
||||||
|
self.write_error(
|
||||||
|
HeaderExpectationFailed(
|
||||||
|
"Unknown Expect: {expect}".format(expect=expect)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_body(self, body):
|
def on_body(self, body):
|
||||||
if self.is_request_stream and self._is_stream_handler:
|
if self.is_request_stream and self._is_stream_handler:
|
||||||
self._request_stream_task = self.loop.create_task(
|
self._request_stream_task = self.loop.create_task(
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -82,6 +82,7 @@ requirements = [
|
||||||
"aiofiles>=0.3.0",
|
"aiofiles>=0.3.0",
|
||||||
"websockets>=6.0,<7.0",
|
"websockets>=6.0,<7.0",
|
||||||
"multidict>=4.0,<5.0",
|
"multidict>=4.0,<5.0",
|
||||||
|
"requests-async==0.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
tests_require = [
|
tests_require = [
|
||||||
|
@ -90,7 +91,6 @@ tests_require = [
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"httpcore==0.3.0",
|
"httpcore==0.3.0",
|
||||||
"requests-async==0.5.0",
|
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
uvloop,
|
uvloop,
|
||||||
ujson,
|
ujson,
|
||||||
|
|
|
@ -687,3 +687,49 @@ def test_register_blueprint(app, debug):
|
||||||
"version 1.0. Please use the blueprint method"
|
"version 1.0. Please use the blueprint method"
|
||||||
" instead"
|
" instead"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_slashes_behavior_adoption(app):
|
||||||
|
app.strict_slashes = True
|
||||||
|
|
||||||
|
@app.get("/test")
|
||||||
|
def handler_test(request):
|
||||||
|
return text("Test")
|
||||||
|
|
||||||
|
assert app.test_client.get("/test")[1].status == 200
|
||||||
|
assert app.test_client.get("/test/")[1].status == 404
|
||||||
|
|
||||||
|
bp = Blueprint("bp")
|
||||||
|
|
||||||
|
@bp.get("/one", strict_slashes=False)
|
||||||
|
def one(request):
|
||||||
|
return text("one")
|
||||||
|
|
||||||
|
@bp.get("/second")
|
||||||
|
def second(request):
|
||||||
|
return text("second")
|
||||||
|
|
||||||
|
app.blueprint(bp)
|
||||||
|
|
||||||
|
assert app.test_client.get("/one")[1].status == 200
|
||||||
|
assert app.test_client.get("/one/")[1].status == 200
|
||||||
|
|
||||||
|
assert app.test_client.get("/second")[1].status == 200
|
||||||
|
assert app.test_client.get("/second/")[1].status == 404
|
||||||
|
|
||||||
|
bp2 = Blueprint("bp2", strict_slashes=False)
|
||||||
|
|
||||||
|
@bp2.get("/third")
|
||||||
|
def third(request):
|
||||||
|
return text("third")
|
||||||
|
|
||||||
|
app.blueprint(bp2)
|
||||||
|
assert app.test_client.get("/third")[1].status == 200
|
||||||
|
assert app.test_client.get("/third/")[1].status == 200
|
||||||
|
|
||||||
|
@app.get("/f1", strict_slashes=False)
|
||||||
|
def f1(request):
|
||||||
|
return text("f1")
|
||||||
|
|
||||||
|
assert app.test_client.get("/f1")[1].status == 200
|
||||||
|
assert app.test_client.get("/f1/")[1].status == 200
|
||||||
|
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sanic.config import BASE_LOGO
|
from sanic.config import BASE_LOGO
|
||||||
|
from sanic.testing import PORT
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -13,7 +14,9 @@ except BaseException:
|
||||||
|
|
||||||
|
|
||||||
def test_logo_base(app, caplog):
|
def test_logo_base(app, caplog):
|
||||||
server = app.create_server(debug=True, return_asyncio_server=True)
|
server = app.create_server(
|
||||||
|
debug=True, return_asyncio_server=True, port=PORT
|
||||||
|
)
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop._stopping = False
|
loop._stopping = False
|
||||||
|
@ -32,7 +35,9 @@ def test_logo_base(app, caplog):
|
||||||
def test_logo_false(app, caplog):
|
def test_logo_false(app, caplog):
|
||||||
app.config.LOGO = False
|
app.config.LOGO = False
|
||||||
|
|
||||||
server = app.create_server(debug=True, return_asyncio_server=True)
|
server = app.create_server(
|
||||||
|
debug=True, return_asyncio_server=True, port=PORT
|
||||||
|
)
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop._stopping = False
|
loop._stopping = False
|
||||||
|
@ -45,13 +50,17 @@ def test_logo_false(app, caplog):
|
||||||
app.stop()
|
app.stop()
|
||||||
|
|
||||||
assert caplog.record_tuples[ROW][1] == logging.INFO
|
assert caplog.record_tuples[ROW][1] == logging.INFO
|
||||||
assert caplog.record_tuples[ROW][2] == "Goin' Fast @ http://127.0.0.1:8000"
|
assert caplog.record_tuples[ROW][
|
||||||
|
2
|
||||||
|
] == "Goin' Fast @ http://127.0.0.1:{}".format(PORT)
|
||||||
|
|
||||||
|
|
||||||
def test_logo_true(app, caplog):
|
def test_logo_true(app, caplog):
|
||||||
app.config.LOGO = True
|
app.config.LOGO = True
|
||||||
|
|
||||||
server = app.create_server(debug=True, return_asyncio_server=True)
|
server = app.create_server(
|
||||||
|
debug=True, return_asyncio_server=True, port=PORT
|
||||||
|
)
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop._stopping = False
|
loop._stopping = False
|
||||||
|
@ -70,7 +79,9 @@ def test_logo_true(app, caplog):
|
||||||
def test_logo_custom(app, caplog):
|
def test_logo_custom(app, caplog):
|
||||||
app.config.LOGO = "My Custom Logo"
|
app.config.LOGO = "My Custom Logo"
|
||||||
|
|
||||||
server = app.create_server(debug=True, return_asyncio_server=True)
|
server = app.create_server(
|
||||||
|
debug=True, return_asyncio_server=True, port=PORT
|
||||||
|
)
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop._stopping = False
|
loop._stopping = False
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import pytest
|
||||||
from sanic.blueprints import Blueprint
|
from sanic.blueprints import Blueprint
|
||||||
|
from sanic.exceptions import HeaderExpectationFailed
|
||||||
from sanic.request import StreamBuffer
|
from sanic.request import StreamBuffer
|
||||||
from sanic.response import stream, text
|
from sanic.response import stream, text
|
||||||
from sanic.views import CompositionView, HTTPMethodView
|
from sanic.views import CompositionView, HTTPMethodView
|
||||||
|
@ -40,6 +42,38 @@ def test_request_stream_method_view(app):
|
||||||
assert response.text == data
|
assert response.text == data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("headers, expect_raise_exception", [
|
||||||
|
({"EXPECT": "100-continue"}, False),
|
||||||
|
({"EXPECT": "100-continue-extra"}, True),
|
||||||
|
])
|
||||||
|
def test_request_stream_100_continue(app, headers, expect_raise_exception):
|
||||||
|
class SimpleView(HTTPMethodView):
|
||||||
|
|
||||||
|
@stream_decorator
|
||||||
|
async def post(self, request):
|
||||||
|
assert isinstance(request.stream, StreamBuffer)
|
||||||
|
result = ""
|
||||||
|
while True:
|
||||||
|
body = await request.stream.read()
|
||||||
|
if body is None:
|
||||||
|
break
|
||||||
|
result += body.decode("utf-8")
|
||||||
|
return text(result)
|
||||||
|
|
||||||
|
app.add_route(SimpleView.as_view(), "/method_view")
|
||||||
|
|
||||||
|
assert app.is_request_stream is True
|
||||||
|
|
||||||
|
if not expect_raise_exception:
|
||||||
|
request, response = app.test_client.post("/method_view", data=data, headers={"EXPECT": "100-continue"})
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.text == data
|
||||||
|
else:
|
||||||
|
with pytest.raises(ValueError) as e:
|
||||||
|
app.test_client.post("/method_view", data=data, headers={"EXPECT": "100-continue-extra"})
|
||||||
|
assert "Unknown Expect: 100-continue-extra" in str(e)
|
||||||
|
|
||||||
|
|
||||||
def test_request_stream_app(app):
|
def test_request_stream_app(app):
|
||||||
"""for self.is_request_stream = True and decorators"""
|
"""for self.is_request_stream = True and decorators"""
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,7 @@ async def test_trigger_before_events_create_server(app):
|
||||||
async def init_db(app, loop):
|
async def init_db(app, loop):
|
||||||
app.db = MySanicDb()
|
app.db = MySanicDb()
|
||||||
|
|
||||||
await app.create_server(debug=True, return_asyncio_server=True)
|
await app.create_server(debug=True, return_asyncio_server=True, port=PORT)
|
||||||
|
|
||||||
assert hasattr(app, "db")
|
assert hasattr(app, "db")
|
||||||
assert isinstance(app.db, MySanicDb)
|
assert isinstance(app.db, MySanicDb)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user