С незапамятных времён на 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\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