Pull to refresh

Механизмы безопасности в Laravel

Level of difficultyHard
Reading time15 min
Views8.3K
Original author: Aaron Francis

Laravel - это веб-фреймворк на PHP с встроенной поддержкой практически всего, что нужно современным приложениям. Но мы не будем рассматривать все эти функции здесь. Вместо этого мы рассмотрим безопасные функции Laravel, которые могут помочь вам предотвратить болезненные ошибки.

Мы рассмотрим следующие механизмы безопасности:

  • Предотвращение N+1

  • Защита от частично гидрированных моделей

  • Опечатки атрибутов и переименованные столбцы

  • Защита от массового присвоения

  • Строгость модели

  • Принудительное выполнение полиморфного сопоставления

  • Мониторинг долгосрочных событий

Предотвращение проблемы N+1

Многие ORM, включая Eloquent, предлагают функцию, которая позволяет лениво загружать отношения модели. Ленивая загрузка удобна, потому что вам не нужно заранее думать о том, какие отношения выбрать из базы данных, но часто это приводит к проблеме производительности, известной как "проблема N+1".

Проблема N+1 - одна из самых распространенных проблем, с которыми сталкиваются люди, использующие ORM, и это часто причина, по которой люди вообще избегают ORM. Мы можем просто отключить ленивую загрузку.

Представьте список статей блога. Мы покажем заголовок блога и имя автора.

$posts = Post::all();

foreach($posts as $post) {
    // `author` is lazy loaded.
    echo $post->title . ' - ' . $post->author->name;
}


Это пример проблемы N+1. Первая строка выбирает все записи блога. Затем для каждой отдельной записи мы выполняем еще один запрос, чтобы получить автора записи.

SELECT * FROM posts;
SELECT * FROM users WHERE user_id = 1;
SELECT * FROM users WHERE user_id = 2;
SELECT * FROM users WHERE user_id = 3;
SELECT * FROM users WHERE user_id = 4;
SELECT * FROM users WHERE user_id = 5;


Обозначение "N+1" происходит от того факта, что для каждой из n-множественных записей, возвращаемых первым запросом, выполняется дополнительный запрос. Один начальный запрос плюс еще n-many, N+1.

Хотя каждый отдельный запрос, вероятно, довольно быстрый, в совокупности вы можете увидеть негативное воздействие на производительность. И поскольку каждый отдельный запрос быстрый, они не появится в вашем журнале медленных запросов.

С Laravel вы можете использовать метод preventLazyLoading в классе Model, чтобы полностью отключить ленивую загрузку. Проблема решена. Действительно, это настолько просто.

Вы можете добавить метод в свой AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventLazyLoading();
}

Теперь каждая попытка ленивой загрузки отношения будет вызывать исключение LazyLoadingViolationException. Вместо ленивой загрузки вам придется явно жадно загружать ваши отношения.

// Eager load the `author` relationship.
$posts = Post::with('author')->get();

foreach($posts as $post) {
    // `author` is already loaded.
    echo $post->title . ' - ' . $post->author->name;
}


Ленивая загрузка отношений не влияет на корректность вашего приложения, а лишь на его производительность. Идеально было бы жадно загружать все необходимые отношения, но если это не происходит, он просто пропускает и лениво загружает необходимые отношения.

Поэтому мы рекомендуем запретить ленивую загрузку в каждой среде, кроме production. Надеемся, что все ленивые загрузки будут обнаружены при локальной разработке или тестировании, но в редком случае, если ленивая загрузка все же попадет в production, ваше приложение продолжит работать так же хорошо, но немного медленнее.

Чтобы предотвратить ленивую загрузку в не-production средах, вы можете добавить это в свой AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Prevent lazy loading, but only when the app is not in production.
    Model::preventLazyLoading(!$this->app->isProduction());
}

Если вы хотите регистрировать неправильную ленивую загрузку в production, вы можете зарегистрировать собственный обработчик нарушения ленивой загрузки, используя статический метод handleLazyLoadingViolationUsing в классе Model.

В приведенном ниже примере мы запрещаем ленивую загрузку в каждой среде, но в production мы записываем нарушение вместо того, чтобы генерировать исключение. Это гарантирует, что наше приложение продолжит работать так, как задумано, но мы сможем вернуться и исправить наши ошибки в ленивой загрузке.

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Prevent lazy loading always.
    Model::preventLazyLoading();

    // But in production, log the violation instead of throwing an exception.
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            $class = get_class($model);

            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

Защита от частично гидрированных моделей

Почти в каждой книге о SQL одной из рекомендаций по повышению производительности, которые вы увидите, будет "выбирать только те столбцы, которые вам нужны". Это хороший совет. Вы хотите, чтобы база данных извлекала и возвращала только данные, которые вы действительно собираетесь использовать, потому что все остальное просто отбрасывается.

До недавнего времени это была сложная (а иногда и опасная) рекомендация для следования в Laravel.

Модели Eloquent в Laravel - это реализация паттерна active record, где каждый экземпляр модели поддерживается строкой в базе данных.

Чтобы получить пользователя с ID 1, вы можете использовать метод User::find() в Eloquent, который выполняет следующий SQL-запрос:

SELECT * FROM users WHERE id = 1;


Ваша модель будет полностью гидрированной, что означает, что каждый столбец из базы данных будет присутствовать в представлении модели в памяти:

$user = User::find(1);
// -> SELECT * FROM users where id = 1;

// Fully hydrated model, every column is present as an attribute.

// App\User {#5522
//   id: 1,
//   name: "Aaron",
//   email: "aaron@example.com",
//   is_admin: 0,
//   is_blocked: 0,
//   created_at: "1989-02-14 08:43:00",
//   updated_at: "2022-10-19 12:45:12",
// }

Выбор всех столбцов в этом случае, вероятно, подходит. Но если ваша таблица пользователей имеет очень широкий формат, содержит столбцы LONGTEXT или BLOB, или вы выбираете сотни или тысячи строк, вам, вероятно, стоит ограничиться только теми столбцами, которые вы собираетесь использовать.

Вы можете контролировать, какие столбцы выбираются с помощью метода select, который приводит к частично гидрированной модели. В памяти модели содержится подмножество атрибутов из строки в базе данных.

$user = User::select('id', 'name')->find(1);
// -> SELECT id, name FROM users where id = 1;

// Partially hydrated model, only some attributes are present.
// App\User {
//   id: 1,
//   name: "Aaron",
// }

Вот где начинаются опасности.

Если вы обращаетесь к атрибуту, который не был выбран из базы данных, Laravel просто возвращает null. Ваш код подумает, что атрибут равен null, но на самом деле он просто не был выбран из базы данных. Он может вообще не быть null.

В следующем примере модель частично гидрирована только с id и name, а затем к атрибуту is_blocked обращаются далее. Поскольку столбец is_blocked не был выбран из базы данных, значение атрибута всегда будет null, что означает, что каждый заблокированный пользователь будет рассматриваться, как если бы он не был заблокирован.

// Partially hydrate a model.
$user = User::select('id', 'name')->find(1);

// is_blocked was not selected! It will always be `null`.
if ($user->is_blocked) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

Этот отдельно взятый случай, вероятно, не произойдет, но когда извлечение и использование данных разнесены по нескольким файлам, что-то подобное может произойти. Нигде нет предупреждения о том, что модель частично гидрирована, и по мере развития проекта и увеличения требований вы можете обратиться к атрибутам, которые никогда не были загружены.

С чрезвычайной осторожностью и 100% покрытием тестами, вы можете предотвратить, возникновение такой ситуации, но это все равно большой риск выстрелить себе в ногу. По этой причине рекомендуется никогда не изменять оператор SELECT, который заполняет модель Eloquent.

Версия Laravel 9.35.0 предоставляет нам новую функцию безопасности, чтобы предотвратить это.

В 9.35.0 вы можете вызвать Model::preventAccessingMissingAttributes(), чтобы предотвратить доступ к атрибутам, которые не были загружены из базы данных. Вместо возврата null будет сгенерировано исключение.

Вы можете включить это новое поведение, добавив это в AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventAccessingMissingAttributes();
}

Обратите внимание, что мы включили эту защиту повсеместно, независимо от окружения. Вы можете включить эту защиту только в локальной разработке, но самое важное место для ее включения - это production.

В отличие от защиты от проблемы N+1, предотвращение доступа к неподгруженным из базы данных атрибутам не является проблемой производительности, это проблема корректности приложения. Включение этой защиты предотвращает непредвиденное и некорректное поведение вашего приложения.

Доступ к атрибутам, которые не были выбраны, может привести к различным катастрофическим последствиям:

  • Потеря данных

  • Перезапись данных

  • Обработка бесплатных пользователей как платных

  • Обработка платных пользователей как бесплатных

  • Отправка фактически неверных электронных писем

  • Отправка одного и того же письма десятки раз

  • И так далее.

Хотя генерация исключений в production неудобна, гораздо хуже иметь бесшумные сбои, которые могут привести к повреждению данных. Лучше столкнуться с исключениями и исправить их.

Опечатки в атрибутах и переименованные столбцы

Это продолжение предыдущего раздела и еще одна просьба включить Model::preventAccessingMissingAttributes() в production.

Мы только что потратили много времени, изучая, как preventAccessingMissingAttributes() защищает вас от частично гидрированных моделей, но есть еще два сценария, в которых этот метод может вас защитить.

Первый - это опечатки.

Продолжая сценарий с is_blocked из предыдущего примера, если вы случайно ошибетесь в написании "blocked", Laravel просто вернет null, вместо того чтобы сообщить вам о вашей ошибке.

// Fully hydrated model.
$user = User::find(1);

// Oops! Spelled "blocked" wrong. Everyone gets through!
if ($user->is_blokced) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

Этот конкретный пример, вероятно, будет обнаружен во время тестирования, но зачем рисковать?

Второй сценарий - это переименованные столбцы. Если ваш столбец изначально назывался blocked, а затем вы решаете, что имеет больший смысл назвать его is_blocked, вам нужно будет убедиться, что вы проверите свой код и обновите каждое упоминание о blocked. А если пропустите одно? Оно просто становится null.

// Fully hydrated model.
$user = User::find(1);

// Oops! Used the old name. Everyone gets through!
if ($user->blocked) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

Включение Model::preventAccessingMissingAttributes() превратит этот бесшумный сбой в явный.

Защита от массового присвоения

Массовое присвоение представляет собой уязвимость, которая позволяет пользователям устанавливать атрибуты, которые им не должны быть разрешены.

Например, если у вас есть свойство is_admin, вы не хотите, чтобы пользователи могли произвольно повышать себя до администратора. Laravel предотвращает это по умолчанию, требуя явного разрешения на массовое присвоение атрибутов.

В этом примере могут быть массово присвоены только атрибуты name и email.

class User extends Model
{
    protected $fillable = [
        'name',
        'email',
    ];
}

Не имеет значения, сколько атрибутов вы передаете при создании или сохранении модели. Сохранятся только атрибуты name и email:

// It doesn’t matter what the user passed in, only `name`
// and `email` are updated. `is_admin` is discarded.
User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'is_admin' => true
]);

Многие разработчики Laravel предпочитают полностью отключить защиту от массового присвоения и полагаться на валидацию запроса для исключения атрибутов. Это вполне разумно. Просто убедитесь, что никогда не передаете $request->all() в методы сохранения модели.

Вы можете добавить это в свой AppServiceProvider, чтобы полностью отключить защиту от массового присвоения.

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // No mass assignment protection at all.
    Model::unguard();
}

Помните: вы рискуете, когда снимаете ограничения с ваших моделей. Убедитесь, что никогда не передаете все данные запроса без разбора.

// Only update `name` and `email`.
User::find(1)->update($request->only(['name', 'email']));

Если вы решите оставить защиту от массового присвоения включенной, есть еще один метод, который вы найдете полезным: метод Model::preventSilentlyDiscardingAttributes().

В случае, когда ваши атрибуты fillable составляют только name и email, а вы пытаетесь обновить атрибут birthday, тогда атрибут birthday будет молча отброшен без предупреждения.

// We’re trying to update `birthday`, but it won’t persist!
User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'birthday' => '1989-02-14'
]);

Атрибут birthday отбрасывается, потому что он не является заполняемым. Это защита от массового присвоения в действии, и это то, что мы хотим. Просто это немного запутывает, потому что ошибка неявная, вместо того чтобы быть явной.

Теперь Laravel предоставляет способ сделать эту неявную ошибку явной:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Warn us when we try to set an unfillable property.
    Model::preventSilentlyDiscardingAttributes();
}

Вместо того чтобы молча отбрасывать атрибуты, будет сгенерировано исключение MassAssignmentException, и вы сразу узнаете, что происходит.

Эта защита очень похожа на защиту preventAccessingMissingAttributes. Она обеспечивает корректность работы приложения. Если вы ожидаете, что данные будут сохранены, но они не сохраняются, это исключение и никогда не должно молчаливо игнорироваться, независимо от среды выполнения.

По этой причине мы рекомендуем включить эту защиту во всех средах выполнения.

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Warn us when we try to set an unfillable property,
    // in every environment!
    Model::preventSilentlyDiscardingAttributes();
}

Строгость модели

В Laravel 9.35.0 предоставляется вспомогательный метод Model::shouldBeStrict(), который контролирует три параметра "строгости" Eloquent:

  • Model::preventLazyLoading()

  • Model::preventSilentlyDiscardingAttributes()

  • Model::preventsAccessingMissingAttributes()

Идея здесь заключается в том, что вы можете использовать вызов shouldBeStrict() в своем AppServiceProvider и включить или отключить все три параметра одним методом. Давайте кратко рассмотрим рекомендации для каждого параметра:

  • preventLazyLoading: В первую очередь для производительности приложения. Отключено для production, включено локально. За исключением случаев, когда вы регистрируете нарушения в production.

  • preventSilentlyDiscardingAttributes: В первую очередь для корректности приложения. Включено везде.

  • preventsAccessingMissingAttributes: В первую очередь для корректности приложения. Включено везде.

Исходя из этого, если вы планируете регистрировать нарушения ленивой загрузки в production, вы можете настроить свой AppServiceProvider следующим образом:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Everything strict, all the time.
    Model::shouldBeStrict();

    // In production, merely log lazy loading violations.
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            $class = get_class($model);

            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

Если вы не планируете регистрировать нарушения ленивой загрузки (что вполне разумное решение), то вы бы настроили свои параметры следующим образом:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // As these are concerned with application correctness,
    // leave them enabled all the time.
    Model::preventAccessingMissingAttributes();
    Model::preventSilentlyDiscardingAttributes();

    // Since this is a performance concern only, don’t halt
    // production for violations.
    Model::preventLazyLoading(!$this->app->isProduction());
}

Принуждение полиморфного сопоставления (Polymorphic mapping enforcement)

Полиморфное отношение - это специальный тип отношения, который позволяет многим типам родительских моделей использовать один и тот же тип дочерней модели.

Например, у блога и пользователя могут быть изображения, и вместо создания отдельной модели изображения для каждого из них вы можете создать полиморфное отношение. Это позволяет иметь единственную модель Image, которая обслуживает как модели Post, так и User. В этом примере Image является полиморфным отношением.

В таблице images вы увидите два столбца, которые Laravel использует для нахождения родительской модели: столбец imageable_type и столбец imageable_id.

Столбец imageable_type хранит тип модели в форме полностью квалифицированного имени класса (FQCN), а imageable_id - первичный ключ модели.

mysql> select * from images;
+----+-------------+-----------------+------------------------------+
| id | imageable_id | imageable_type | url                          |
+----+-------------+-----------------+------------------------------+
|  1 |           1 | App\Post        | https://example.com/1001.jpg |
|  2 |           2 | App\Post        | https://example.com/1002.jpg |
|  3 |           3 | App\Post        | https://example.com/1003.jpg |
|  4 |       22001 | App\User        | https://example.com/1004.jpg |
|  5 |       22000 | App\User        | https://example.com/1005.jpg |
|  6 |       22002 | App\User        | https://example.com/1006.jpg |
|  7 |           4 | App\Post        | https://example.com/1007.jpg |
|  8 |           5 | App\Post        | https://example.com/1008.jpg |
|  9 |       22003 | App\User        | https://example.com/1009.jpg |
| 10 |       22004 | App\User        | https://example.com/1010.jpg |
+----+-------------+-----------------+------------------------------+

Это стандартное поведение Laravel, но хранить полностью квалифицированные имена классов (FQCN) в вашей базе данных не является хорошей практикой. Привязка данных в вашей базе данных к определенному имени класса очень хрупкая и может привести к непредвиденным нарушениям, если вы когда-либо рефакторите свои классы.

Чтобы предотвратить это, Laravel дает нам способ контролировать, какие значения попадают в базу данных, с помощью метода Relation::morphMap. Используя этот метод, вы можете дать каждому классу, который используется в полиморфном отношении, уникальный ключ, который никогда не изменяется, даже если имя класса изменится:

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    Relation::morphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

Теперь мы разорвали связь между именем нашего класса и данными, хранящимися в базе данных. Вместо \App\User в базе данных, мы увидим user.

Однако мы все еще подвержены одной потенциальной проблеме: это отображение не обязательно. Мы могли бы создать новую модель Comment и забыть добавить ее в morphMap, и Laravel будет использовать FQCN по умолчанию, оставив данные беспорядке.

mysql> select * from images;
+----+-------------+-----------------+------------------------------+
| id | imageable_id | imageable_type | url                          |
+----+-------------+-----------------+------------------------------+
|  1 |           1 | post            | https://example.com/1001.jpg |
|  2 |           2 | post            | https://example.com/1002.jpg |
| .. |         ... | ....            |  . . . . . . . . . . . . . . |
| 10 |       22004 | user            | https://example.com/1010.jpg |
| 11 |          10 | App\Comment     | https://example.com/1011.jpg |
| 12 |          11 | App\Comment     | https://example.com/1012.jpg |
| 13 |          12 | App\Comment     | https://example.com/1013.jpg |
+----+-------------+-----------------+------------------------------+

Некоторые из наших значений imageable_type корректно отображены, но поскольку мы забыли указать модель App\Comment в методе Relation::morphMap, полностью квалифицированное имя класса все еще попадает в базу данных.

Laravel предоставляет нам метод для обеспечения того, чтобы каждая преобразованная модель была сопоставлена. Вы можете изменить свой вызов morphMap на enforceMorphMap, и поведение по умолчанию использования полностью квалифицированного имени класса отключается.

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    // Enforce a morph map instead of making it optional.
    Relation::enforceMorphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

Теперь, если вы попытаетесь использовать новое преобразование, которое вы не отобразили, вам будет предложено исключение ClassMorphViolationException, которое вы можете исправить до того, как плохие данные попадут в базу данных.

Самые хитрые сбои - это те, которые происходят без предупреждения; всегда лучше иметь явные сбои.

Предотвращение случайных HTTP-запросов

При тестировании вашего приложения обычно подделывают исходящие запросы к сторонним сервисам, чтобы можно было контролировать различные сценарии тестирования.

В Laravel у нас есть способ сделать это уже давно, вызвав Http::fake(), который подделывает все исходящие HTTP-запросы. Однако чаще всего вы хотите подделать конкретный запрос и предоставить ответ:

use Illuminate\Support\Facades\Http;

// Fake GitHub requests only.
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

В этом сценарии исходящие HTTP-запросы к любому другому домену не будут подделаны и будут отправлены как обычные HTTP-запросы. Вы можете не заметить этого, пока не поймете, что определенные тесты медленные, или пока не начнете сталкиваться с ограничениями скорости.

В Laravel 9.12.0 был введен метод preventStrayRequests, чтобы защитить вас от ошибочных запросов.

use Illuminate\Support\Facades\Http;

// Don’t let any requests go out.
Http::preventStrayRequests();

// Fake GitHub requests only.
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

// Not faked, so an exception is thrown.
Http::get('https://planetscale.com');

Это еще одна хорошая защита, которую всегда стоит включать. Если ваши тесты должны обращаться к внешним службам, вы должны явно разрешить это. Если у вас есть базовый класс теста, я рекомендую поместить его в метод setUp этого базового класса:

protected function setUp(): void
{
    parent::setUp();

    Http::preventStrayRequests();
}

В любых тестах, где вам нужно разрешить отправку неподдельных запросов, вы можете снова включить это, вызвав Http::allowStrayRequests() в этом конкретном тесте.

Мониторинг долгосрочных событий

Эти последние несколько методов не направлены на предотвращение отдельных некорректных поведений, а скорее на мониторинг всего приложения. Эти методы могут быть полезны, если у вас нет инструмента мониторинга производительности приложения.

Долгие запросы к базе данных

В Laravel 9.18.0 был представлен метод DB::whenQueryingForLongerThan(), который позволяет запускать callback, когда совокупное время выполнения всех ваших запросов превышает определенный порог.

use Illuminate\Support\Facades\DB;

public function boot()
{
    // Log a warning if we spend more than a total of 2000ms querying.
    DB::whenQueryingForLongerThan(2000, function (Connection $connection) {
        Log::warning("Database queries exceeded 2 seconds on {$connection->getName()}");
    });
}

Если вы хотите запустить callback, когда один запрос занимает много времени, вы можете сделать это с помощью обратного вызова DB::listen.

use Illuminate\Support\Facades\DB;

public function boot()
{
    // Log a warning if we spend more than 1000ms on a single query.
    DB::listen(function ($query) {
        if ($query->time > 1000) {
            Log::warning("An individual database query exceeded 1 second.", [
                'sql' => $query->sql
            ]);
        }
    });
}

Опять же, эти методы полезны, если у вас нет инструмента мониторинга производительности приложения или инструмента мониторинга запросов.

Жизненный цикл запроса и команды

Подобно мониторингу долгих запросов, вы можете отслеживать, когда жизненный цикл вашего запроса или команды занимает больше определенного порога. Оба эти метода доступны начиная с Laravel 9.31.0.

use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;

public function boot()
{
    if ($this->app->runningInConsole()) {
        // Log slow commands.
        $this->app[ConsoleKernel::class]->whenCommandLifecycleIsLongerThan(
            5000,
            function ($startedAt, $input, $status) {
                Log::warning("A command took longer than 5 seconds.");
            }
        );
    } else {
        // Log slow requests.
        $this->app[HttpKernel::class]->whenRequestLifecycleIsLongerThan(
            5000,
            function ($startedAt, $request, $response) {
                Log::warning("A request took longer than 5 seconds.");
            }
        );
    }
}

Делайте неявное явным

Многие из этих функций безопасности Laravel превращают неявные поведения в явные исключения. В начале проекта легко помнить все неявные поведения, но со временем легко забыть одно или два из них и оказаться в ситуации, когда ваше приложение не ведет себя так, как ожидалось.

У вас уже достаточно забот. Снимите часть нагрузки с себя, включив эти защитные механизмы.

Tags:
Hubs:
Total votes 23: ↑21 and ↓2+19
Comments5

Articles