Импортируем пользователей

Сегодня мы обновим нашу модель пользователя, чтобы отражать изменения таблицы. Добавим всякие методы для сброса, активации и реализуем проверку паролей старых юзеров из MODX.
Проверять это всё будем путём импорта пользователей из тестовой БД, которую я получил путём обфускации данных рабочей базы.
Затем обновим админку для отображения новых данных, а вишенкой на торте будет загрузка аватарок.

Обновление модели User

Первым делом нужно прописать новые свойства класса для автозаполнения в PhpStorm и других IDE:
/**
 * @property int $id
 * @property string $username
 * @property ?string $fullname
 * @property string $password
 * @property ?string $tmp_password
 * @property ?string $salt
 * @property ?string $email
 * @property ?string $phone
 * @property ?int $gender
 * @property ?string $company
 * @property ?string $address
 * @property ?string $country
 * @property ?string $city
 * @property ?string $zip
 * @property ?string $lang
 * @property int $role_id
 * @property ?int $file_id
 * @property bool $active
 * @property bool $blocked
 * @property ?string $comment
 * @property ?int $remote_id
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read UserRole $role
 * @property-read File $file
 * @property-read UserToken[] $tokens
 */
Из-за того, что свойств теперь очень много, мне не хочется прописывать их все в $fillable, поэтому меняем переменные в начале класса вот так:
    protected $guarded = ['id', 'remote_id', 'created_at', 'updated_at'];
    protected $fillable = [];
    protected $hidden = ['password', 'tmp_password', 'salt'];
Теперь разрешено заполнять все свойства модели, кроме указанных в $guarded.
Свойства, связанные с паролями, будут убираться при выводе данных через toArray() и прочие подобные методы.
Пароли в MODX могут хэшироваться двумя методами (на самом деле больше, но то седая старина и нам неинтересно): modPBKDF2 и modNative.
Первый метод - устаревший, но мы будем его поддерживать для того, чтобы старые юзеры могли зайти на новый сайт без смены пароля. Но если юзер поменяет свой позже пароль, мы сохраним его через нативную функцию PHP password_hash. То есть, старые пароли - это временная поддержка, от которой мы стараемся избавиться.
О типе хэширования пароля мы будем судить по наличию или отсутствию аттрибута salt у модели.
    // Переопределяем метод выставления аттрибутов модели
    public function setAttribute($key, $value)
    {
        if ($key === 'password' && !empty($value)) {
             // При смене пароля удаляем salt, он не нужен для native
            parent::setAttribute('salt', null);
        } elseif ($key === 'tmp_password') {
            // При создании временного пароля при сбросе, хэшируем значение
            $value = password_hash($value, PASSWORD_DEFAULT);
        }

        // Во всех остальных случаях передаём данные в родительский метод
        return parent::setAttribute($key, $value);
    }

    // Функция проверки пароля
    public function verifyPassword(string $password): bool
    {
        $hash = $this->getAttribute('password');
        if ($salt = $this->getAttribute('salt')) {
            // Если указан salt, проверяем хэш по алгоритму из modx/hashing/modpbkdf2.class.php
            $pbkdf2 = base64_encode(hash_pbkdf2('sha256', $password, $salt, 1000, 32, true));
            // Хэши равны - проверка пройдена
            if ($hash === $pbkdf2) {
                return true;
            }
        }

        // Если salt пустой, проверяем нативной функцией
        return password_verify($password, $hash);
    }
Логику активации аккаунта и сброса пароля мы пропишем позже в контроллерах, но уже сейчас можно предусмотреть 2 простых метода:
    // Юзер сбрасывает пароль
    public function resetPassword($length = 20): string
    {
        // Генерируем случайную строку заданной длины
        $tmp = bin2hex(random_bytes($length));
        // И сохраняем как временный пароль - он будет захэширован автоматически
        $this->setAttribute('tmp_password', $tmp);
        $this->save();

        // Затем его нужно будет как-то отправить юзеру для подверждения
        // Например, по почте
        return $tmp;
    }

    // Активация аккаунта
    public function activate(string $password): bool
    {
        $hash = $this->getAttribute('tmp_password');
        // Проверяем присланный пароль от юзера
        if (!password_verify($password, $hash)) {
            return false;
        }

        // Если всё ок - активируем
        $this->active = true;
        $this->tmp_password = null;
        $this->save();

        // Затем юзера можно авторизовать и пусть он сам меняет, что хочет
        return true;
    }
Осталось только дописать связи с другими моделями.
    public function file(): BelongsTo
    {
        return $this->belongsTo(File::class);
    }

    public function tokens(): HasMany
    {
        return $this->hasMany(UserToken::class);
    }

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

Импорт пользователей из MODX

Скрипты импорта мы будем писать в наши сидеры, потому что их задача заполнить базу данными. Я предлагаю скачать вам мой дамп, чтобы импортировать в свою БД.
ms2.sql.zip
2.93 MB, application/zip
В этом дампе используется префикс modx_, так что нашим таблицам ничего не помешает. Я постарался оставить только нужные таблицы и убрать все персональные данные: имена, адреса и телефоны. Заказы, товары и прочие связи оставлены как есть.
Редактируем core/db/seeds/Users.php:
<?php

use App\Models\File;
use App\Models\User;
use Phinx\Seed\AbstractSeed;
use Slim\Psr7\UploadedFile;
use Vesp\Services\Eloquent;

class Users extends AbstractSeed
{
    // Указываем основные группы для работы
    public const ROLE_ADMIN = 1;
    public const ROLE_USER = 2;

    // Перед запуском этого сида требуется засеять группы
    public function getDependencies(): array
    {
        return ['UserRoles'];
    }

    public function run(): void
    {

        // Работаем с таблицей MODX через чистый PDO, без моделей
        $db = (new Eloquent())->getDatabaseManager();
        $pdo = $db->getPdo();
        // Выбираем юзеров с присоединением их профилей
        $stmt = $pdo->query(
            "SELECT `user`.*, `profile`.* FROM `modx_users`  `user`
            INNER JOIN `modx_user_attributes` `profile` ON `user`.`id` = `profile`.`internalKey`
            ORDER BY `user`.`id`"
        );
        $stmt->execute();

        // Перебираем результаты выборки по одному
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            // Юзеры Vesp и MODX связаны через колонку remote_id
            /** @var User $user */
            if (!$user = User::query()->where('remote_id', $row['internalKey'])->first()) {
                $user = new User();
            }

            // Сохраняем пароль юзера в "сыром" виде, без повторного хэширования
            $user->setRawAttributes([
                'password' => $row['password'],
                // Сохраняем соль только для старых учёток
                'salt' => $row['hash_class'] === 'hashing.modPBKDF2' ? $row['salt'] : null,
            ]);

            // Заполняем соответствующие поля модели
            $user->remote_id = $row['internalKey'];
            $user->username = $row['username'];
            $user->fullname = $row['fullname'];
            $user->active = !empty($row['active']);
            $user->blocked = !empty($row['blocked']);
            $user->email = trim($row['email']);
            $user->phone = trim($row['phone']) ?: trim($row['mobilephone']);
            $user->gender = empty($row['gender']) ? null : (int)$row['gender'];
            $user->address = trim($row['address']) ?: null;
            $user->country = trim($row['country']) ?: null;
            $user->city = trim($row['city']) ?: null;
            $user->zip = trim($row['zip']) ?: null;
            // Группу определяем по наличию sudo
            $user->role_id = !empty($row['sudo']) ? self::ROLE_ADMIN : self::ROLE_USER;
            $user->created_at = date('Y-m-d H:i:s', $row['createdon'] ?: time());

            // Я почистил extended колонк в дампе, но оставлю пример её обработки
            if (!empty($row['extended']) && $tmp = json_decode($row['extended'], true)) {
                $user->company = !empty($tmp['company']) ? trim($tmp['company']) : null;
                $user->lang = !empty($tmp['language']) ? trim($tmp['language']) : null;
            }
            $user->save();

            // Обработка фотографии пользователя
            if ($row['photo']) {
                // Реальных файлов у нас нет, так что меняем на плейсхолдер,
                // который сохраняем во временный файл
                $filename = tempnam(getenv('UPLOAD_DIR'), 'img_');
                file_put_contents($filename, file_get_contents('https://i.pravatar.cc/500'));
                // Создаём из него экземпляр загруженного файла
                $data = new UploadedFile($filename, basename($filename), mime_content_type($filename));
                if (!$file = $user->file) {
                    $file = new File();
                }
                // Дальше Vesp всё вделает сам
                $file->uploadFile($data);
                // Остаётся только сохранить id файла в модель юзера
                $user->update(['file_id' => $file->id]);
                // И удалить временный файл
                unlink($filename);
            }
        }
    }
}
Засеивание пользователей можно запускать сколько угодно раз - дубликатов не будет из-за связи данных через колонку remote_id в модели User.
Теперь запускаем миграцию групп пользователей composer db:seed-one UserRoles, а затем и самих пользователей composer db:seed-one Users:
В итоге получаем заполненные таблицы групп и пользователей.
Можно авторизоваться а админке по адресу http://127.0.0.1:4000/admin/ - я предусмотрительно поменял в дампе первых 2х юзеров на admin и user с такими же паролями.

Обновление админки

В таблице юзеров у нас теперь много новых данных - нужно отразить их и в админке.
Первым делом добавляем новые записи в лексиконы. Немецкий язык я выкидываю, чтобы не возиться лишний раз, тем более что я его совсем не знаю. Кому надо - добавит его с помощью перевода английского лексикона.
Я не буду приводить изменения в src/admin/lexicons, там ничего интересного, посмотрите в итоговом коммите.
Дальше обновляем таблицу пользователей пользователей:
  • в динамическую переменную fields добавляем file
  • в самой таблице добавляем вывод аватарки, в специальном слоте, с помощью встроенной в Vesp функции $image - она генерирует ссылку на картинку по id файла:
<template>
  <div>
    <vesp-table
     // ...
    >
      <template #cell(file)="{value}">
        <b-img
          v-if="value.id"
          :key="value.id"
          :src="$image(value, {w: 75, h: 75, fit: 'crop'})"
          :srcset="$image(value, {w: 150, h: 150, fit: 'crop'}) + ' 2x'"
          width="75"
          height="75"
          class="rounded-circle"
        />
      </template>
    </vesp-table>
//...
Даже с поддержкой экранов высокой плотности!
Теперь нужно доработать контроллер вывода юзеров (core/src/Controllers/Admin/Users.php), чтобы включить туда получение файла:
    protected function beforeGet(Builder $c): Builder
    {
        $c->with('file:id,updated_at');

        return $c;
    }

    protected function afterCount(Builder $c): Builder
    {
        $c->with('role:id,title');
        $c->with('file:id,updated_at');

        return $c;
    }
У модели прописана связь с файлом, так что здесь всё очень просто.
Вы должны получить такую таблицу юзеров:
Аватарка будет только у 1й записи, потому что так в исходном дампе. Причём картинка будет случайная при каждом запуске засеивания юзеров.
Теперь добавляем новые поля в форму юзеров (src/admin/components/forms/user.vue). Я не буду расписывать все поля ввода, только интересные.
Выбор пола юзера выглядит так:
    <b-form-group :label="$t('models.user.gender')">
      <b-form-select v-model="record.gender" :options="genderOptions" />
    </b-form-group>
А данные формируются в script вот так:
  computed: {
    // ...
    genderOptions() {
      return [
        {value: null, text: this.$t('models.user.gender_select')},
        {value: 1, text: this.$t('models.user.gender_1')},
        {value: 2, text: this.$t('models.user.gender_2')},
      ]
    },
  },
Мы используем динамическую переменную для того, чтобы пол юзера реагировал на смену языка админки, потому что варианты берутся из лексиконов. Ровно так же это работает и с переменной fields() в таблице юзеров.
Загрузку аватарки я выношу в отдельный новый универсальный компонент frontend/src/admin/components/file-upload.vue. Постараюсь объяснить, как он работет в сокращенном виде:
<template>
  <section>
    <!-- Стили и классы элемента формируются динамически в script -->
    <!-- Элемент реагирует на обычный клик мышкой  -->
    <!-- Элемент реагирует на события drag-n-drop  -->
    <div
      :class="classes"
      :style="styles"
      @drop.prevent="onAddFile"
      @dragenter.prevent="onDragEnter"
      @dragleave.prevent="onDragLeave"
      @dragover.prevent
      @click="onSelect"
    >
      <!-- В качестве изображения может выводиться указанный плейхолдер  -->
      <img v-if="placeholder && myValue !== false" :src="placeholderUrl" alt="" />
      <!-- И/или выбранная новая картинка -->
      <img v-if="myValue && myValue.file" :src="myValue.file" alt="" />
      <!-- Новая картинка стилями выводится поверх плейсхолдера -->
    </div>

    <!-- Предусматриваем расширяемые слоты для кнопок управления -->
    <slot name="actions" v-bind="{select: onSelect, remove: onRemove, cancel: onCancel, value: myValue, placeholder}">
      <div class="text-center">
        <b-button v-if="myValue && myValue.file" variant="link" class="text-danger" @click="onCancel">
          {{ titleCancel }}
        </b-button>
        <b-button v-else-if="placeholder && myValue !== false" variant="link" class="text-danger" @click="onRemove">
          {{ titleRemove }}
        </b-button>
      </div>
    </slot>
  </section>
</template>

<script>
export default {
  name: 'FileUpload',
  props: {
    value: {
      type: [Object, Boolean],
      default() {
        return {file: null, metadata: null}
      },
    },
    // ...
    // Можно передать уже загруженную картинку в компонент
    placeholder: {
      type: Object,
      default() {
        return null
      },
    },
    // Можно указать, какие принимаются файлы через mime
    accept: {
      type: String,
      default: 'image/*',
    },
    // ...
  },
  data() {
    return {
      dragCount: 0,
    }
  },
  computed: {
    // ...
    classes() {
      // Тут возвращаются классы оформления в зависимости от наведения
      // мышкой с файлом и т.д.
    },
    placeholderUrl() {
      if (!this.placeholder) {
        return null
      }
      // ...
      // Для вывода переданного плейсхолдера опять используем $image
      return this.$image(this.placeholder, params)
    },
  },
  methods: {
    // При клике на блок с картинкой создаём файловый input
    onSelect() {
      const input = document.createElement('input')
      input.type = 'file'
      input.multiple = false
      input.accept = this.accept
      // При изменении инпута запускаем метод добавления
      input.onchange = (e) => {
        this.onAddFile({dataTransfer: {files: e.target.files}})
      }
      // Инициализируем вывод диалога выбора файла браузером
      input.click()
    },
    // ...
    // Собственно метод добавления файла
    onAddFile({dataTransfer}) {
      this.dragCount = 0
      // dataTransfer всегда массив, из которого мы получаем первый и единственный файл
      const file = Array.from(dataTransfer.files).shift()
      // Проверяем его mime
      if (this.verifyAccept(file.type)) {
        const reader = new FileReader()
        // Вешаем событие на завершение чтения файла, чтобы добавить его
        // как base64 в значение v-model нашего компонента
        reader.onload = () => {
          this.$set(this, 'myValue', {
            file: reader.result,
            metadata: {name: file.name, size: file.size, type: file.type},
          })
        }
        // Запускаем асинхронное чтение файла
        reader.readAsDataURL(file)
      }
    },
    // ...
    // Метод проверки mime файла
    verifyAccept(type) {
      const allowed = this.accept.split(',').map((i) => i.trim())
      return allowed.includes(type) || allowed.includes(type.split('/')[0] + '/*')
    },
  },
}
</script>
Я довольно долго мучался с разными готовыми загрузчиками файлов, в итоге плюнул на это дело и разобрался сам. В итоге получилось всё гораздо проще и компактнее.
Компонент содержит 1 HTML блок, в котором может быть до 2х картинок: старая и новая. Они спозиционированы стилями относительно родительского элемента, так что при выборе новой картинки, она всегда перекрывает старую.
Дальше вы можете убрать новую картинку - и тогда опять покажется плейхолдер.
Через переменную dragCount мы следим за количеством наведений мышкой с зажатым файлом на элемент. Javascript генерирует новые события enter и leave при переносе мышки между элементами компонента, через это получаются глюки с оформлением. Поэтому я делаю +1 при новом enter и -1 при leave.
Если dragCount > 0, то юзер таскает файл над нашим компонентом и можно это стилизовать дополнительным классом CSS. Если dragCount === 0, то класс убирается - вот такой простой трюк.
Само чтение файла производится чистым Javascript FileAPI, в результате на сервер полетит или base64 строка для загрузки новой картинки, false для удаления старой или null - на него контроллер реагировать не будет.
Теперь добавляем новый компонент в форму редактирования юзера:
<template>
    <!-- -->
        <b-form-group>
          <file-upload
            v-model="record.new_file"
            :placeholder="record.file"
            :height="150"
            :width="150"
            wrapper-class="rounded-circle"
          />
        </b-form-group>
    <!-- -->
</template>
<script>
import FileUpload from '@/components/file-upload.vue'
export default {
  name: 'FormUser',
  components: {FileUpload},
// ...
</script>
Таким образом в record.new_file мы и получаем или base64, или false или null, который полетит на сервер.
Работу с файлами на PHP выносим в универсальный трейт, чтобы подключать его по необходимости в любом контроллере для поддержки загрузки файлов - Controllers/Traits/FileModelController.php:
<?php

namespace App\Controllers\Traits;

use App\Models\File;
use Illuminate\Database\Eloquent\Model;
use Psr\Http\Message\ResponseInterface;

/**
 * @method getProperty(string $key, $default = null)
 * @method failure(string $message, int $code = 422)
 * @property array $attachments
 */
trait FileModelController
{
    protected function processFiles(Model $record): ?ResponseInterface
    {

        // Проверяем свойство контроллера с именами файловых колонок
        foreach ($this->attachments as $attachment) {
            /** @var File $file */
            $file = $record->$attachment;
            // Если у модели уже есть файл, и новым значением прислано false
            // то удаляем старый файл и выходим
            if ($file && $this->getProperty("new_$attachment") === false) {
                $file->delete();
            } elseif ($tmp = $this->getProperty("new_$attachment", $this->getProperty($attachment))) {
                // Если же прислано не пустое значение
                if (!$file) {
                    $file = new File();
                }

                // Пробуем его загрузить в качестве нового файла
                if (!empty($tmp['file']) && $file->uploadFile($tmp['file'], $tmp['metadata'])) {
                    $record->{"{$attachment}_id"} = $file->id;
                }
            }
        }

        return null;
    }

    // По умолчанию обрабатываем загрузку файлов перед сохранением модели
    protected function beforeSave(Model $record): ?ResponseInterface
    {
        if ($error = $this->processFiles($record)) {
            return $error;
        }

        return null;
    }
}
Логика такая:
  • Трейт проверяет переменную $attachments у контроллера
  • Если там перечислены колонки с файлами, то проверяет присланные данные
  • Если прислан false, то удаляет старый файл
  • Если прислана строка, то готовит из неё новый файл для загрузки
  • Если модель уже имеет старый файл, он будет перезаписан
  • Загрузку файлов можно вызывать вручную из контроллера методом processFiles(), по умолчанию он вызывается перед сохранением модели.
Теперь этот трейт нужно указать контроллеру Users:
use App\Controllers\Traits\FileModelController;

class Users extends ModelController
{
    use FileModelController;
    // ...
    public $attachments = ['file'];
Так как в нашем контроллере не расширяется метод beforeSave, достаточно только этих изменений - всё будет работать по умолчанию.
Итоговый результат:

Заключение

Так мы обновили модель юзеров, импортировали старых юзеров из таблиц MODX и доработали админку для из вывода и загрузки аватарок.
На следующем занятии займёмся категориями товаров.
Итоговый коммит со всеми изменениями.

16 комментариев

ms2.sql.zip - ссылка нерабочая на этот файл.
Василий Наумкин
Спасибо, перезалил.
Я функцию загрузки zip файлов вчера специально для этого файла реализовал, и кое-где не доработал, так что залитый файл считался неиспользованым и был удалён по cron.
То есть, когда я проверял - всё работало, а утром перестало. Теперь исправлено!
Сергей Лелеко
Привет! Пока вопрос, как узнал что именно такие параметры должны быть в данной функции ? hash_pbkdf2('sha256', $password, $salt, 1000, 32, true)
Василий Наумкин
Из исходников MODX, я же там оставил ссылку, просто неполную.
А прежде чем выполнять
composer db:seed-one UserRoles, а затем и самих пользователей composer db:seed-one Users
нам нужно "физически" залить ms2.sql в свою базу в Докере?
Василий Наумкин
Для ролей - нет, а для юзеров - да.
Это легко понять, поглядев на исходники сидов.
Прошу прощения за тупой вопрос - а как правильно импортировать файл с базой MODX в существующую в Докере базу? Как обычно консольной командой:
mysql -u <username> -p <databasename> < <filename.sql>
?
Василий Наумкин
Нужно еще хост 127.0.0.1 и порт 3333 указать.
Что-то я никак не осилю это импорт. Загрузил дамп ms2.sql в деректорию /vesp-shop/docker и там пытаюсь импортнуть командами типа:
docker exec -i vesp-shop-mariadb-1 mysql -h 127.0.0.1 -P3333 -u vesp -p vesp < ms2.sql
Получаю ошибку:
Enter password: ERROR 2002 (HY000): Can't connect to server on '127.0.0.1' (115)
Василий Наумкин
Хост 127.0.0.1 и порт 3333 нужно указывать только если ты подключаешься снаружи, из хостовой машины. Например, через PhpStorm или из консоли.
Изнутри контейнера докера эти параметры указывать не нужно.
Я подключаюсь в консоли PhpStorm и обращаюсь с контейнеру vesp-shop-mariadb-1. Пытаюсь подключаться используя данные БД (пользователь, пароль)
docker exec -i vesp-shop-mariadb-1 mysql -u vesp -p vesp < ms2.sql
получаю ошибку
Enter password: ERROR 1045 (28000): Access denied for user 'vesp'@'localhost' (using password: NO)
Пытаюсь подключиться от root
docker exec -i vesp-shop-mariadb-1 mysql -uroot -proot vesp < ms2.sql
получаю ошибку
ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)
Башку сломал! Курс срывается из-за небольшого технического затыка, который не могу осилить))
Василий Наумкин
получаю ошибку (using password: NO)
Ну так нужно не просто -p указать, а -pvesp
docker exec -i vesp-shop-mariadb-1 mysql -u vesp -pvesp vesp < ms2.sql
Только это всё равно не сработает, потому что внутри контейнера нет файла ms2.sql, его ж туда никто не примонтировал.
Поэтому я и говорил изначально подключаться снаружи, указывая хост и порт.
Блин, а я положил файл ms2.sql в директорию /docker и думал подключусь к контейнеру, укажу путь и этого достаточно... Значит нужно mysql на Windows теперь ставить. Спасибо за помощь!
Василий Наумкин
Или так, или положить файл в уже примонтированную директорию, например core, которая в PHP контейнере.
Дальше, по идее, должно сработать вот так (не проверял):
docker exec -i vesp-shop-php-fpm-1 mysql -hmariadb -uvesp -pvesp vesp < /vesp/core/ms2.sql
Из одного контейнера к другому можно обращаться по сети, используя псевдоним из docker-compose - поэтому я и указываю -hmariadb.
Спасибо! Запихнули-таки общими усилиями! Запустил консоль Openserver (на нем стоит mysql) и подключился с указанием порта и хоста. Засеял в итоге базу и админка открылась с юзерами.
Василий Наумкин
Отлично, поздравляю!
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!