Работа с картинками
С незапамятных времён на bezumkin.ru нельзя было загружать картинки, даже когда такая возможность появилась в дополнении MODX Tickets, на котором сайт был построен.
Но теперь, в связи с полным перезапуском проекта, я обновил и работу с картинками. Вы можете загружать их кнопочкой в редакторе или сразу бросать пачкой в текст.
А дальше я расскажу, как это работает.
Обработка старых картинок
Картинки могут быть только в заметках и комментариях, так что для работы с ними я создал один общий 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
0
👍
👎
❤️
🔥
😮
😢
😀
😡
461
24.03.2022, 21:28:22
Комментарии
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
23.12.2024, 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Вывод товаров на сайте
21
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Начинаем новый курс!
14
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Запуск в продакшн
55
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
День рождения 42
5
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Vesp 3.0
108
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Оплата заказа
2
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Обновление проекта
2
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Создание нового проекта
63
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Поездка в Швейцарию
8
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
День рождения 41
6
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!