webprogr.ru Новости

RSS

{{toodo.preview}}

...
{{toodo.name}}

{{toodo.preview}}

далее..

{{toodo.preview}}

{{toodo.name}}

{{toodo.preview}}

смотреть..

toodo.name

{{toodo.preview}}

{{toodo.name}}

{{toodo.preview}}

перейти...

{{toodo.preview}}

...
{{toodo.name}}

{{toodo.preview}}

далее..

{{toodo.preview}}

{{toodo.name}}

{{toodo.preview}}

смотреть..

toodo.name

{{toodo.preview}}

{{toodo.name}}

{{toodo.preview}}

перейти...


Последние новости

Лабораторная работа IX. Загрузка файлов, изображения и Flysystem

Лабораторная работа 9. Загрузка файлов, изображения и Flysystem

Часть 1

Минимальные основы JavaScript: события, файлы, DOM и JSON

0.1. Цель практикума

Подготовьте небольшую страницу, которая:

  • реагирует на действия пользователя;
  • показывает сведения о выбранном изображении и его предварительный просмотр;
  • получает JSON по HTTP и отображает данные без перезагрузки страницы.

В этом упражнении файл ещё не загружается на сервер, а MySQL не используется. Сначала изучается работа JavaScript в браузере. Серверное сохранение файлов будет реализовано в основной части лабораторной.

0.2. Как JavaScript связан с PHP и Symfony

HTML задаёт структуру страницы, CSS — оформление, а JavaScript выполняет действия в браузере: обрабатывает нажатия, читает значения полей, отправляет запросы и изменяет содержимое страницы.

PHP и Symfony выполняются на сервере. JavaScript не должен получать пароль MySQL и обращаться к БД напрямую.

В готовом приложении взаимодействие будет устроено так:


Браузер и JavaScript
        ↓ HTTP-запрос
Symfony: проверка пользователя, данных и прав
        ↓
Сервисы, Doctrine, MySQL или файловое хранилище
        ↓ HTTP-ответ
JavaScript обновляет страницу















Работа с элементами HTML из JavaScript выполняется через DOM — объектное представление документа.

0.3. Создание песочницы

В общем учебном репозитории создайте:


sandbox/
└── js-basics/
    ├── index.html
    ├── app.js
    └── products.json















Песочница располагается отдельно от Symfony-приложения.

Из корня репозитория запустите:




php -S 127.0.0.1:8082 -t sandbox/js-basics


Откройте:


http://127.0.0.1:8082/index.html















Не открывайте страницу двойным щелчком через file://: в упражнении нужен HTTP-запрос к JSON-файлу.

Встроенный сервер PHP используется здесь только для локального обучения.

0.4. Минимальный синтаксис JavaScript

Откройте инструменты разработчика браузера и вкладку Console / Консоль.

Выполните:




const product = {
    id: 1,
    name: 'Клавиатура',
    priceCents: 125000,
    active: true
};

let quantity = 2;

function calculateTotal(priceCents, quantity) {
    return priceCents * quantity;
}

if (quantity > 0 && product.active === true) {
    console.log(calculateTotal(product.priceCents, quantity));
}

const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/webp'
];

for (const type of allowedTypes) {
    console.log(type);
}


Ожидаемый результат первого расчёта:


250000















Основные конструкции:

Конструкция Значение
const Переменная, которой нельзя присвоить другое значение.
let Переменная, значение которой можно переназначать.
{ id: 1, name: '...' } Объект со свойствами.
['a', 'b'] Массив.
product.name Чтение свойства объекта.
function Объявление функции.
if Условное выполнение.
for...of Перебор значений массива.
=== Строгое сравнение без приведения типов.
&&, `
null Явно заданное отсутствие значения.
console.log() Вывод в консоль разработчика.


const не делает объект неизменяемым: можно изменить product.name, но нельзя присвоить переменной product другой объект.

Значение текстового поля — строка. Для числовой операции выполните явное преобразование:




const rawQuantity = '2';
const parsedQuantity = Number(rawQuantity);

if (Number.isInteger(parsedQuantity) && parsedQuantity > 0) {
    console.log(parsedQuantity + 1); // 3
}


Без преобразования выражение '2' + 1 даст строку '21'.

0.5. HTML-страница

В index.html добавьте:




<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>BizFlow: JavaScript</title>
    <script src="app.js" defer></script>

    <style>
        body {
            max-width: 50rem;
            margin: 2rem auto;
            padding: 0 1rem;
            font-family: sans-serif;
        }

        img {
            max-width: 100%;
            max-height: 400px;
        }
    </style>
</head>
<body>
    <h1>JavaScript: файлы и JSON</h1>

    <section>
        <h2>Предпросмотр изображения</h2>

        <label for="image">Выберите JPEG, PNG или WebP</label>
        <input
            id="image"
            type="file"
            accept="image/jpeg,image/png,image/webp"
        >

        <button id="clear" type="button">Очистить</button>

        <p id="file-message" role="status">Файл не выбран.</p>

        <img
            id="preview"
            alt="Предпросмотр выбранного файла"
            hidden
        >
    </section>

    <section>
        <h2>Загрузка JSON</h2>

        <button id="load" type="button">Получить товары</button>

        <p id="api-message" role="status"></p>
        <ul id="products"></ul>
    </section>
</body>
</html>


Атрибут defer откладывает выполнение этого скрипта до завершения разбора HTML. Поэтому элементы с нужными id уже доступны, когда JavaScript начинает их искать.

0.6. Выбор файла и предварительный просмотр

В app.js добавьте:




'use strict';

const MAX_BYTES = 5_000_000;
const ALLOWED_TYPES = [
    'image/jpeg',
    'image/png',
    'image/webp'
];

const input = document.querySelector('#image');
const preview = document.querySelector('#preview');
const fileMessage = document.querySelector('#file-message');

let previewUrl = null;

function checkFile(file) {
    if (file.size === 0) {
        return 'Пустой файл.';
    }

    if (file.size > MAX_BYTES) {
        return 'Файл больше 5 MB.';
    }

    if (!ALLOWED_TYPES.includes(file.type)) {
        return 'Браузер не определил допустимый тип изображения.';
    }

    return '';
}

function clearPreview() {
    preview.hidden = true;
    preview.removeAttribute('src');

    if (previewUrl !== null) {
        URL.revokeObjectURL(previewUrl);
        previewUrl = null;
    }
}

input.addEventListener('change', () => {
    clearPreview();

    const file = input.files[0];

    if (!file) {
        fileMessage.textContent = 'Файл не выбран.';
        return;
    }

    const error = checkFile(file);

    if (error !== '') {
        fileMessage.textContent = error;
        return;
    }

    fileMessage.textContent =
        `${file.name}: ${file.size} байт; ${file.type}`;

    previewUrl = URL.createObjectURL(file);
    preview.src = previewUrl;
});

preview.addEventListener('load', () => {
    if (
        preview.naturalWidth > 6000 ||
        preview.naturalHeight > 6000
    ) {
        fileMessage.textContent =
            'Размеры изображения больше 6000 пикселей по одной из сторон.';

        clearPreview();
        return;
    }

    preview.hidden = false;
});

preview.addEventListener('error', () => {
    fileMessage.textContent =
        'Браузер не смог прочитать изображение.';

    clearPreview();
});

document.querySelector('#clear').addEventListener('click', () => {
    input.value = '';
    clearPreview();
    fileMessage.textContent = 'Файл не выбран.';
});


Что происходит в этом коде

document.querySelector('#image') находит элемент по CSS-селектору. Символ # означает поиск по id.

addEventListener('change', ...) назначает обработчик изменения выбранного файла. Запись () => { ... } — стрелочная функция; в данном примере она вызывается браузером при наступлении события.

input.files[0] получает первый выбранный файл. У него используются свойства:


name — имя;
size — размер в байтах;
type — тип, сообщённый браузером.















URL.createObjectURL(file) создаёт временный адрес для локального просмотра. URL.revokeObjectURL() освобождает ранее созданный адрес, когда изображение больше не нужно. Это не загрузка файла на сервер.

textContent вставляет текст, а не HTML. Используйте его для имени файла и других пользовательских строк; не заменяйте его на innerHTML.

Ограничение клиентской проверки: accept и file.type не доказывают подлинность изображения. Значение типа может определяться по расширению или оказаться пустым. Все проверки необходимо повторить на сервере.

Размер картинки на странице, ограниченный CSS, не изменяет исходный файл и не создаёт миниатюру. Настоящая миниатюра будет создана сервером в основной части лабораторной.

0.7. JSON: данные для первого HTTP-запроса

В products.json добавьте:




{
  "data": [
    {
      "id": 1,
      "name": "Клавиатура",
      "priceCents": 125000
    },
    {
      "id": 2,
      "name": "Мышь",
      "priceCents": 75000
    }
  ]
}


Это статический учебный файл, а не выгрузка из MySQL.

В JSON имена свойств и строковые значения заключаются в двойные кавычки. Не добавляйте комментарии и лишнюю запятую после последнего элемента.

0.8. Получение JSON через fetch()

В конец app.js добавьте:




const loadButton = document.querySelector('#load');
const apiMessage = document.querySelector('#api-message');
const productList = document.querySelector('#products');

loadButton.addEventListener('click', async () => {
    loadButton.disabled = true;
    productList.replaceChildren();
    apiMessage.textContent = 'Загрузка...';

    try {
        const response = await fetch('./products.json');

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const result = await response.json();

        if (!Array.isArray(result.data)) {
            throw new Error('Ожидался массив data.');
        }

        for (const product of result.data) {
            const item = document.createElement('li');

            item.textContent =
                `${product.name}: ${product.priceCents} коп.`;

            productList.append(item);
        }

        apiMessage.textContent =
            `Получено товаров: ${result.data.length}`;
    } catch (error) {
        apiMessage.textContent =
            error instanceof Error
                ? error.message
                : 'Ошибка запроса.';
    } finally {
        loadButton.disabled = false;
    }
});


Разбор асинхронного запроса

fetch() отправляет запрос и возвращает Promise — объект будущего результата.

async позволяет использовать внутри функции await. Оператор await ожидает завершения операции в этой функции, не блокируя весь интерфейс браузера.

response.json() разбирает JSON-ответ и возвращает JavaScript-значение.

try содержит основную операцию, catch обрабатывает ошибку, а finally возвращает кнопку в доступное состояние независимо от результата.

HTTP-ошибку нужно проверять самостоятельно. Ответ 404 или 422 обычно не вызывает исключение непосредственно в fetch(). Поэтому перед чтением успешного результата проверяется response.ok.

0.9. Проверка в браузере

Откройте Console / Консоль и Network / Сеть.

Выполните:

Действие Ожидаемый результат
Выбрать небольшое корректное изображение Появляются сведения о файле и предварительный просмотр.
Нажать «Очистить» Выбор и предварительный просмотр удаляются.
Выбрать файл больше 5 000 000 байт Появляется сообщение об ограничении размера.
Выбрать повреждённый файл с расширением изображения Предпросмотр не появляется; выводится ошибка.
Нажать «Получить товары» В Network виден GET-запрос к products.json; на странице появляются два товара.
Повторно нажать кнопку Список обновляется, а не дублируется.
Заменить data на пустой массив Отображается количество 0.
Временно переименовать products.json Отображается ошибка HTTP; кнопка снова доступна.


После проверки восстановите исходное имя JSON-файла.

Самостоятельно внесите три изменения: добавьте третий товар, измените текст успешного сообщения и добавьте вывод размеров изображения после его загрузки.

Не копируйте изменение целиком из ИИ без объяснения того, в каком обработчике оно должно выполняться.

0.10. Перенос в Symfony

После песочницы переходите к основной части лабораторной IX.

В Symfony используйте уже созданные серверные маршруты и формы. При переносе предварительного просмотра согласуйте реальные id полей формы с селекторами JavaScript.

После подключения AJAX:


JavaScript → HTTP → Symfony → проверка и сохранение → ответ















Для отправки файлов используется FormData. При передаче FormData через fetch() не задавайте Content-Type вручную: браузер добавит необходимый multipart-разделитель. Сессионная аутентификация, CSRF-проверки и серверная валидация должны сохраняться.

Проверка формы в браузере нужна для удобства пользователя. Она не заменяет проверку размера, содержимого, прав доступа и безопасного имени файла на сервере.

0.11. Запрос к ИИ и результат


Ты — преподаватель JavaScript и разработчик Symfony.

Проверь мою учебную песочницу:
переменные и функции;
поиск элементов DOM;
обработчики change, click, load и error;
проверку размера файла;
освобождение object URL;
вывод пользовательских строк через textContent;
fetch, response.ok, обработку JSON и ошибок.

Не добавляй библиотеки и JavaScript-фреймворки.
Не заменяй серверную проверку клиентской.
Сначала перечисли ошибки, затем предложи минимальные исправления.

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















Сохраните:


sandbox/js-basics/index.html
sandbox/js-basics/app.js
sandbox/js-basics/products.json















В журнал docs/ai/lab-09.md добавьте использованные запросы, исправления и фактические результаты проверки.




Часть 2

Цель: реализовать безопасное хранение медиафайлов.

Symfony представляет загруженные файлы объектами UploadedFile и рекомендует не доверять исходным имени и расширению, предоставленным пользователем. [25] Flysystem предоставляет абстракцию файлового хранилища; официальный Symfony bundle позволяет заменять local storage, cloud storage и memory storage через единый интерфейс. [26]

Задание

  1. Установите:

composer require league/flysystem-bundle

  1. Настройте:

media.storage

  1. Локальное хранилище разместите:

var/storage/media

  1. Не храните оригинальные пользовательские имена как физические имена файлов.

  2. Создайте сущность:

Media

  1. Поля:

id
storageKey
originalName
mimeType
size
checksum
width
height
createdAt
uploadedBy
product

  1. Создайте форму загрузки изображения товара.

  2. Разрешите:

image/jpeg
image/png
image/webp

  1. Максимальный размер:

5 MB

  1. Проверяйте MIME type на сервере.

  2. Генерируйте собственный случайный storageKey.

  3. Запрещено использовать напрямую:

getClientOriginalName()

в качестве физического имени файла.

  1. Для каждого изображения вычисляйте:

SHA-256

  1. Получайте:

width
height

  1. Отбрасывайте изображения:

width > 6000
height > 6000

  1. Реализуйте создание thumbnail:

максимум 400×400

  1. Сохраняйте пропорции.

  2. Создайте сервис:

MediaStorage

  1. Контроллер не должен непосредственно работать с файловой системой.

  2. Реализуйте:

POST /products/{id}/media
GET /media/{id}
DELETE /media/{id}

  1. При удалении записи Media удаляйте физический файл.

  2. При ошибке сохранения записи БД не оставляйте потерянный файл.

  3. При ошибке записи файла не сохраняйте запись Media.

  4. Реализуйте проверку доступа к удалению.

  5. Проверьте загрузку файла, переименованного из:

malware.php

в:

photo.jpg

Такой файл не должен проходить проверку только на основании расширения.

  1. Добавьте тестовый Flysystem memory adapter.

Официальная интеграция Flysystem предусматривает memory storage для тестового окружения. [27]

  1. Напишите тесты:

валидный JPEG;
валидный PNG;
файл > 5 MB;
неизвестный MIME;
текстовый файл с .jpg;
удаление;
отсутствующий Media;
доступ без роли;
ошибка storage.

Обязательный запрос ИИ-агенту

Ты — security-focused Symfony developer.

Спроектируй безопасную загрузку изображений.

Требования:
Symfony UploadedFile;
Validator;
Flysystem;
UUID/random storage key;
проверка MIME;
ограничение размера;
проверка dimensions;
SHA-256;
thumbnail;
Entity Media;
удаление физического файла;
authorization.

Особо проверь:
path traversal;
double extension;
поддельный Content-Type;
исполнение загруженного PHP;
перезапись существующего файла.

Сначала предложи архитектуру.

Обязательный запрос ИИ-тестировщику

Попытайся сломать механизм загрузки файлов.

Создай тестовые случаи:
../file.jpg
../../x.php
shell.php.jpg
shell.jpg.php
HTML вместо JPEG
SVG с JavaScript
слишком большое изображение
нулевой файл
повторное имя
неверный MIME
удаление чужого файла

Преврати сценарии в автоматизированные тесты там, где возможно.

Самопроверка

php bin/console lint:yaml config
php bin/phpunit

Критерии приемки

[ ] используется Flysystem
[ ] имеется Media entity
[ ] проверяется MIME
[ ] ограничивается размер
[ ] проверяются dimensions
[ ] генерируется безопасное имя
[ ] рассчитывается SHA-256
[ ] создается thumbnail
[ ] проверяется authorization
[ ] memory storage используется в тестах
[ ] негативные тесты проходят
[ ] имеется AI log

Symfony рекомендует не доверять metadata, полученной от пользователя при upload, а Flysystem позволяет отделить application logic от конкретного backend хранения. [28]

Рейтинг@Mail.ru