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

Сегодня мы обновим нашу модель пользователя, чтобы отражать изменения таблицы. Добавим всякие методы для сброса, активации и реализуем проверку паролей старых юзеров из 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.79 Mb)

В этом дампе используется префикс 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)
bezumkinВасилий Наумкин
16.08.2023 10:40

Спасибо, перезалил.

Я функцию загрузки zip файлов вчера специально для этого файла реализовал, и кое-где не доработал, так что залитый файл считался неиспользованым и был удалён по cron.

То есть, когда я проверял - всё работало, а утром перестало. Теперь исправлено!

bezumkinВасилий Наумкин
17.08.2023 12:05

Из исходников MODX, я же там оставил ссылку, просто неполную.

https://github.com/modxcms/revolution/blob/2.x/core/model/modx/hashing/modpbkdf2.class.php

bezumkinВасилий Наумкин
20.11.2023 10:33

Для ролей - нет, а для юзеров - да.

Это легко понять, поглядев на исходники сидов.

bezumkinВасилий Наумкин
20.11.2023 12:48

Нужно еще хост 127.0.0.1 и порт 3333 указать.

bezumkinВасилий Наумкин
21.11.2023 07:09

Хост 127.0.0.1 и порт 3333 нужно указывать только если ты подключаешься снаружи, из хостовой машины. Например, через PhpStorm или из консоли.

Изнутри контейнера докера эти параметры указывать не нужно.

bezumkinВасилий Наумкин
21.11.2023 15:36

получаю ошибку (using password: NO)

Ну так нужно не просто -p указать, а -pvesp

docker exec -i vesp-shop-mariadb-1 mysql -u vesp -pvesp vesp < ms2.sql

Только это всё равно не сработает, потому что внутри контейнера нет файла ms2.sql, его ж туда никто не примонтировал.

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

bezumkinВасилий Наумкин
22.11.2023 01:10

Или так, или положить файл в уже примонтированную директорию, например core, которая в PHP контейнере.

Дальше, по идее, должно сработать вот так (не проверял):

docker exec -i vesp-shop-php-fpm-1 mysql -hmariadb -uvesp -pvesp vesp < /vesp/core/ms2.sql

Из одного контейнера к другому можно обращаться по сети, используя псевдоним из docker-compose - поэтому я и указываю -hmariadb.

bezumkinВасилий Наумкин
22.11.2023 08:09

Отлично, поздравляю!

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...