Работа с картинками

С незапамятных времён на bezumkin.ru нельзя было загружать картинки, даже когда такая возможность появилась в дополнении MODX Tickets, на котором сайт был построен.
Но теперь, в связи с полным перезапуском проекта, я обновил и работу с картинками. Вы можете загружать их кнопочкой в редакторе или сразу бросать пачкой в текст.
Выглядит это вот так (нажмите на картинку для запуска GIFки):
А дальше я расскажу, как это работает.

Обработка старых картинок

Картинки могут быть только в заметках и комментариях, так что для работы с ними я создал один общий Trait. Указал его для моделей Topic и Comment, а дальше можно вызывать новые методы в контроллерах, или работать с ними напрямую из консоли.
Проблема, которую я хотел решить - это возможная потеря картинок, вставленных в заметки с чужих сайтов. Эти сайты могут быть временнно недоступны, или закрыты насовсем. Или, в конце-концов, они могут вместо старой картинки подсунуть что-то новое, нехорошее.
Поэтому один из методов проверяет все ссылки на картинки с чужих сайтов, выкачивает их и заменяет на короткую запись вида image/uuid, которую при выводе содержимого я могу поменять на реальный адрес https://bezumkin.ru/api/image/uuid.
UUID используется для того, чтобы загруженные картинки нельзя было перебрать, как это можно делать с инткрементируемыми ID.
При загрузке чужих файлов используется UUID версии 5, который генерирует одну и ту же строку в зависимости от адреса файла, чтобы не грузить одно и то же несколько раз. А вот при загрузке новых изображении из редактора используется UUID 4,он генерирует полностью случайную строку.
Для работы c UUID рекомендую ramsey/uuid. Кому интересно - вот сам код загрузки
$replace = [];

preg_match_all('#!\[.*?]\((http.*?)\)#i', $content, $images);
// Перебираем массив картинок из текста
foreach ($images[1] as $uri) {
    // Идентификатор генерируем из адреса ссылки 
    $uuid = Uuid::uuid5(Uuid::NAMESPACE_URL, $uri);

    // Если такой адрес уже был - обновим, если нет - создадим новый файл
    if (!$file = File::query()->find($uuid)) {
        $file = new File();
        $file->id = $uuid;
    }

    // Качаем картинку
    if ($data = @file_get_contents($uri)) {
        // Сохраняем данные во временный файл
        $tmp = tempnam(getenv('CACHE_DIR'), 'download_');
        file_put_contents($tmp, $data);

        // Проверяем MIME, вдруг это и не картинка вовсе?
        if (($mime = mime_content_type($tmp)) && strpos($mime, 'image/') === 0) {
            // Всё ок, загружаем данные в хранилище
            $file->uploadFile('data:' . $mime . ';base64,' . base64_encode($data));
            // Сохраняем в массив для замены
            $replace[$uri] = $uuid;
            unlink($tmp);
        } else {
            unlink($tmp);
            throw new RuntimeException('Wrong file type: ' . $uri);
        }
    } else {
        throw new RuntimeException('Filed to download file: ' . $uri);
    }
}

// Теперь меняем старые ссылки на новые
if (!empty($replace)) {
    foreach ($replace as $from => $to) {
        $content = str_replace($from, 'image/' . $to, $content);
    }
}
У меня этот код чуть сложнее, для наглядности я убрал всё лишнее.

Загрузка новых картинок

Новые картинки нам ниоткуда скачивать не нужно, тут всё делается на стороне пользователя.
Благодаря тому, что у меня используется свой собственный редактор, я легко добавил ему новое действие при клике на кнопку добавления картинки:
onImageClick() {
  const input = document.createElement('input')
  input.type = 'file'
  input.multiple = true
  input.accept = 'image/*'
  input.onchange = (e) => {
    this.onAddFiles({dataTransfer: {files: e.target.files}})
  }
  input.click()
},
Сама загрузка происходит в следующем методе:
onAddFiles({dataTransfer}) {
  const files = Array.from(dataTransfer.files)
  files.forEach((file) => {
    // Работаем только с картинками
    if (file.type.includes('image/')) {
      const reader = new FileReader()
      // Задаём функцию обработки файла, когда браузер его прочитает
      reader.onload = async () => {
        // Отправляем данные на сервер
        const {data: res} = await this.$axios.post('upload', {
          file: reader.result,
          metadata: {name: file.name, size: file.size, type: file.type}
        })
        // И вставляем в текст тег с полученным в ответ UUID
        this.$refs.editor.insertText(`\n![](image/${res.id})\n`)
      }
      // Запускаем чтение файла
      reader.readAsDataURL(file)
    }
  })
},
Обратите внимание, что файл браузером читается асинхронно. То есть, при выборе файла никто не ждёт, пока он загрузится полностью, и браузер не останавливает для этого все процессы.
Наоборот, задаётся функция, которая будет запущена по прочтению всего файла, и она отправит его на сервер, и вставит в редактор тег с картинкой.
Дальше уже моя функция перевода Markdown в HTML подставит для короткой ссылки реальный адрес на сервере.
Drag and drop использует эту же функцию для загрузки файлов. В Vue это делается очень просто:
<form @drop.prevent="onAddFiles" @dragover.prevent @submit.prevent="onSubmit">
...
</form>
Осталось только написать контроллер загрузки файлов:
<?php

namespace App\Controllers;

use App\Models\File;
use Psr\Http\Message\ResponseInterface;
use Ramsey\Uuid\Uuid;
use Vesp\Controllers\Controller;

class Upload extends Controller
{
    protected $scope = 'profile';

    public function post(): ResponseInterface
    {
        if ($file = $this->getProperty('file')) {
            $object = new File();
            $object->id = Uuid::uuid4();
            $object->user_id = $this->user->id;
            $object->uploadFile($file, $this->getProperty('metadata'));

            return $this->success($object->only(['id', 'width', 'height']));
        }

        return $this->failure('errors.upload.no_file');
    }
}
Файл прилетает в кодировке base64, которую файлы Vesp понимают по умолчанию.

Контроль целостности БД

Пока пишется заметка можно накидать много файлов, а потом их убрать и не использовать. Такие файлы, конечно же, на сервере не нужны. Хотелось бы их как-то чистить.
Для этого я просто ставлю вновь созданным файлам флаг temporary, который снимается при сохранении заметки с его UUID.
А что делать, если юзер отредактирует заметку и уберёт ссылку на файл? Для контроля подобных случаем я создал 2 новые модели: TopicFile и CommentFile, в которые просто пишутся id соответствующей модели и файла.
Затем при сохранении модели мы проверяем какие файлы к ней относятся, и если их нет в содержимом, то удаляем эту запись из БД, а соответствующему ей файлу снова ставим флаг temporary.
public function getFileIds(): array
{
    if (preg_match_all('#image/(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})#', $this->content, $matches)) {
        return array_unique($matches[1]);
    }

    return [];
}

public function deleteLocalFiles(): void
{
    // Если у модели есть привязанные файлы
    if ($files = $this->files()->get()) {
        $ids = $this->getFileIds(); // То смотрим, какие есть ссылки в контента

        // Перебираем привязанное и проверяем, есть ли оно в контенте
        /** @var TopicFile|CommentFile $file */
        foreach ($files as $file) {
            // Если файла нет, то
            if (!in_array($file->file_id, $ids, true)) {
                // Удаляем привязку
                $file->delete();
                // И ставим на удаление загруженный файл
                $file->file->temporary = true;
                $file->file->save();
            }
        }
    }
}
Остаётся только добавить в crontab команду на удаление temporary файлов раз в неделю.

Заключение

Таким образом bezumkin.ru, наконец-то, хранит файлы сам у себя. Ничего не потеряется, данные ни от кого постороннего не зависят. А вам, мои дорогие читатели, должно быть гораздо удобнее теперь добавлять картинки в комментариях.
По ходу написания этой заметки выяснилось, что библиотека thephpleague/glide, которая используется в Vesp для вывода изображений, калечит анимированные GIFки.
Поэтому я дописал специальную обработку для таких файлов в контроллере, и если запрос приходит без параметров для конвертации, то GIFка отдаётся как есть, с анимацией. Именно так и работает превьюшка в начале заметки - статичная картинка с ограничением по ширине до 850px, является ссылкой на полную немодифицированную, с анимацией.
Если вы используете vesp/core, то можно обновляться на версию 2.6.0

Комментарии

bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
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
Спасибо!