JavaRush /Курси /Spring Security /Збирання POST /api/me/avat...

Збирання POST /api/me/avatar без хаосу

Spring Security
Рівень 13 , Лекція 4
Відкрита

1. Акуратне збирання важливіше за «аби працювало»

Коли ви вперше робите кінцеву точку для завантаження, майже неминуче виникає бажання «швиденько» написати все в одному методі контролера: взяти MultipartFile, перевірити розмір, придумати імʼя, зберегти на диск, оновити профіль і повернути відповідь. Це працює рівно до першої помилки — а потім ви отримуєте код, який важко тестувати, складно розширювати і страшно чіпати. Майже як вежа з Jenga, зібрана у пʼятницю ввечері.

Проблема в тому, що POST /api/me/avatar насправді поєднує одразу кілька різних світів. Один світ — це межа веб-рівня: multipart, параметри, статуси відповідей. Другий світ — це безпека: аутентифікація, сесія і CSRF, які спрацьовують ще до контролера. Третій світ — прикладний: що саме ми змінюємо в профілі користувача. Четвертий — інфраструктурний: де і як фізично зберігати файл. Якщо все це змішати в одному місці, ви отримаєте метод, який неможливо прочитати очима і швидко зрозуміти, що він робить і чому.

Тут завдання просте: зібрати вже знайомі частини в один спокійний потік. В одному місці має бути зрозумілий доступ до кінцевої точки, окремо — CSRF, окремо — перевірка файла, окремо — storage і оновлення профілю.

2. Модель відповідальності кінцевої точки

Коли ми говоримо «зібрати кінцеву точку», корисно мислити не класами, а шарами. У сценарії завантаження є чітка логіка проходження запиту: спочатку security вирішує, чи можна взагалі продовжувати, потім MVC пов’язує multipart-частини з параметрами методу, потім прикладна логіка робить свою роботу — валідацію, збереження, оновлення профілю. Якщо ви тримаєте цей шлях у голові, то код автоматично починає розкладатися по правильних місцях, як речі по коробках під час переїзду: «ложки — сюди, документи — сюди, дроти — в окремий пакет, інакше потім будете тиждень шукати зарядку».

Намалюємо простий «маршрут» запиту:

flowchart TD
    A[Клієнт: POST /api/me/avatar
multipart/form-data] --> B[Ланцюг фільтрів безпеки
Аутентифікація + CSRF] B -->|OK| C[Контролер Spring MVC
звʼязування MultipartFile] C --> D[AvatarService
оркестрація сценарію використання] D --> E[AvatarFileValidator
перевірки типу/розміру] D --> F[AvatarStorageService
збереження на диск] D --> G[UserProfileService
оновлення avatarPath] B -->|DENY| H[403/401
запит відхилено до контролера]

Зверніть увагу на важливу річ: контролер тут не «перевіряє CSRF» і не «вирішує доступ». Якщо запит дійшов до контролера, значить шар безпеки вже сказав: «окей, цей запит має право жити». Контролер в ідеалі робить дві речі: приймає вхідні дані (у нашому випадку MultipartFile) і передає їх далі в сервіс.

3. Правила доступу для /api/me/avatar

Дуже хочеться зробити кінцеву точку для завантаження «якось окремо», наприклад /api/avatar/upload, а потім додати туди параметр userId, щоб «усе було універсально». У навчальному проєкті це майже гарантований шлях до помилок доступу: у вас з’являється можливість випадково завантажити аватар комусь іншому, а це вже зовсім інший рівень правил, який нам зараз не потрібен.

Для нашого проєкту ідеальне рішення просте: аватар належить поточному користувачеві, отже кінцева точка живе в зоні /api/me/**. Тоді приналежність тут виражається не складними перевірками «мій/чужий», а самою формою URL: немає userId, отже ви фізично не можете «завантажити іншому користувачеві» через цю кінцеву точку. Це чудовий приклад того, як дизайн API допомагає безпеці.

У браузерно-сеансовій моделі є ще одна службова кінцева точка — /csrf, через яку клієнт може отримати токен для поточної сесії. Вона не живе під /api/me/**, тому у фінальній карті доступу її потрібно відкрити окремо: це не бізнес-операція, а початковий етап для запитів, що змінюють стан.

Покажемо фрагмент SecurityFilterChain, де видно місце цієї зони. Тут важливо, що ми не робимо жодних «спеціальних дірок» під аватар — він успадковує правило особистої зони.

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Службова кінцева точка: клієнт отримує CSRF token для поточної сесії
            .requestMatchers("/csrf").permitAll()

            // Публічна зона: доступна без аутентифікації
            .requestMatchers("/api/public/**").permitAll()

            // Особиста зона: будь-які запити вимагають залогіненого користувача
            .requestMatchers("/api/me/**").authenticated()

            // Усе інше за замовчуванням забороняємо, щоб не залишати «випадкові» кінцеві точки відкритими
            .anyRequest().denyAll()
        )
        // Залишаємо stateful-логіку входу; CSRF за замовчуванням увімкнений і продовжує працювати
        .formLogin(Customizer.withDefaults())
        .build();
}

Зверніть увагу, що ми нічого не робимо з CSRF у цьому фрагменті. І це добре. У stateful/browser-моделі CSRF залишається увімкненим. Якщо хтось у проєкті запропонує: «А давайте вимкнемо CSRF, а то Postman лається», ви тепер знаєте, що це не лікування, а ампутація.

4. Тонкий контролер для завантаження

Коли студент уперше бачить MultipartFile, він часто починає поводитися з ним як із «звичайним об’єктом»: ну файл і файл, що там. Але контролер — це межа застосунку, а межа має бути зрозумілою і короткою. Чим товстіша межа, тим більша ймовірність, що вона почне жити своїм життям і тягнути всередину бізнес-логіку, безпеку та інфраструктурні деталі.

Наша мета — написати контролер так, щоб по ньому було видно три речі. Перша: це кінцева точка саме завантаження, тому consumes = multipart/form-data. Друга: імʼя частини (part) очікується як "file". Третя: сервіс отримує імʼя поточного користувача і файл, а далі вже розбирається сам.

Ось компактний контролер для POST /api/me/avatar:

package com.example.securecontent.profile.web;

import java.security.Principal;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class MeAvatarController {

    private final AvatarService avatarService;

    public MeAvatarController(AvatarService avatarService) {
        this.avatarService = avatarService;
    }

    @PostMapping(path = "/api/me/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public void uploadAvatar(
        // Чекаємо multipart-частину з конкретною назвою: "file"
        @RequestParam("file") MultipartFile file,

        // Беремо поточного користувача з security-контексту через стандартний Principal
        Principal principal
    ) {
        // Контролер не перевіряє і не зберігає файл — він лише делегує сценарій у сервіс
        avatarService.uploadAvatar(principal.getName(), file);
    }
}

Тут є маленька, але важлива ідея: ми беремо поточного користувача через Principal. Це максимально «недраматичний» спосіб у MVC: нам не потрібно лізти в SecurityContextHolder, не потрібно тягнути security-код у сервіс, і при цьому ми явно передаємо в сценарій використання те, що йому потрібно: хто завантажує аватар.

Ще один нюанс — повертаємо void. Для навчального проєкту це нормально: якщо все успішно, клієнт отримує 200 OK (або 204 No Content, якщо ви так налаштуєте). Якщо ви хочете зробити більш явний контракт, можна повернути простий JSON із новим avatarPath, але не перетворюйте це на окрему історію про роздавання файлів — ми свідомо не розширюємо цю тему сьогодні.

5. Сервіс-оркестратор і порядок кроків

Тепер переносимо справжню роботу в сервіс. У сценарії завантаження важливо не тільки що ви робите, а й в якому порядку. Якщо ви спочатку збережете файл на диск, а потім почнете перевіряти його тип і розмір, то будь-яка помилка валідації вже залишить сміття в storage. Якщо ви спочатку оновите профіль, а потім збереження файла впаде, ви отримаєте профіль, який посилається на неіснуючий файл. Тож порядок валідація → збереження → оновлення профілю — це не естетика, а спосіб не зламати дані.

Зберемо це в одному сервісі, який відповідає за сценарій «користувач завантажив аватар»:

package com.example.securecontent.file;

import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Service
public class AvatarService {

    private final AvatarFileValidator validator;
    private final AvatarStorageService storage;
    private final UserProfileService profiles;

    public AvatarService(AvatarFileValidator validator,
                         AvatarStorageService storage,
                         UserProfileService profiles) {
        this.validator = validator;
        this.storage = storage;
        this.profiles = profiles;
    }

    public void uploadAvatar(String username, MultipartFile file) {
        // Спочатку перевіряємо вхідні дані: інакше можна зберегти сміття в storage
        validator.validate(file);

        // Потім зберігаємо файл і отримуємо серверне імʼя (те, що реально лежить на диску)
        String storedName = storage.store(file);

        // І лише після успішного збереження оновлюємо профіль користувача
        profiles.setAvatarPath(username, storedName);
    }
}

Це виглядає майже занадто просто — і це чудова ознака. Хороша архітектура часто відчувається як «ну так, логічно». Якщо ваш сервіс розрісся до 200 рядків і містить три рівні try/catch, це не означає «ви дорослий розробник», це означає, що ви тягнете занадто багато відповідальності в одну точку.

Тут AvatarService — оркестратор. Він не зобов’язаний знати, як саме валідовувати файл, і не зобов’язаний знати, як саме зберігати файл. Він має знати, що в сценарії є ці кроки і що вони виконуються послідовно.

6. Валідація файла: базові перевірки

Перевірка файла — це той шар, який багатьом здається «не security, а просто валідація». На практиці це і є частина безпеки: ви обмежуєте вхідні дані, щоб кінцева точка не стала важкою, небезпечною або просто безглуздою. Важливо пам’ятати, що contentType і originalFilename приходять від клієнта. Вони корисні як сигнал, але не є «істинною в останній інстанції».

Зробімо невеликий валідатор. Він перевірятиме три речі: файл не порожній, розмір не перевищує ліміт, тип у білому списку.

package com.example.securecontent.file;

import java.util.Set;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Component
public class AvatarFileValidator {

    // Ліміт розміру для аватара: «захист від величезних файлів» на рівні застосунку
    private static final long MAX_BYTES = 2 * 1024 * 1024;

    // Дозволяємо лише очікувані MIME-типи (білий список)
    private static final Set<String> ALLOWED_TYPES = Set.of("image/png", "image/jpeg");

    public void validate(MultipartFile file) {
        // Порожній файл — це по суті відсутній файл
        if (file.isEmpty()) throw new IllegalArgumentException("Потрібен файл аватара");

        // Перевірка розміру: не даємо зберігати й обробляти занадто важкі запити
        if (file.getSize() > MAX_BYTES) throw new IllegalArgumentException("Аватар завеликий");

        // contentType приходить від клієнта: використовуємо його як фільтр, але пам’ятаємо, що це не «криптографічна гарантія»
        if (!ALLOWED_TYPES.contains(file.getContentType())) {
            throw new IllegalArgumentException("Непідтримуваний тип аватара");
        }
    }
}

Для базового мінімуму нам достатньо isEmpty + size + білого списку для contentType. Якщо хочеться жорсткіше відсікати зайве, зверху можна додати спробу прочитати зображення через ImageIO, але це вже посилення, а не обов’язковий мінімум.

Зверніть увагу, що ми не намагаємося тут «розпізнати картинку по байтах» і не робимо глибокий аналіз вмісту. Для нашого курсу це надто далеко. Ми чесно обмежуємо вхід і показуємо правильну звичку: білий список, а не «дозволено все, доки не зламається».

7. Зберігання: імʼя, каталог, гігієна шляху

Зберігання — це інфраструктурна частина. І в неї є дві типові помилки новачків. Перша: «збережу під originalFilename, так буде красиво». Друга: «збережу в якусь папку, потім розберуся». Обидві помилки ведуть до проблем. originalFilename може бути дивним, конфліктним або взагалі містити неочікувані символи. А «якась папка» ви потім забудете, і проєкт почне обростати випадковими файлами в корені репозиторію.

Нам потрібен простий, але дисциплінований storage service. Він має зберігати файли в одному каталозі, створювати його за потреби, генерувати серверне імʼя файла і утримувати цільовий шлях у межах цього каталогу навіть тоді, коли «і так усе виглядає безпечно».

Покажемо, як зберігати шлях через property:

package com.example.securecontent.file;

import java.nio.file.Path;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class AvatarStorageService {

    // Каталог задається конфігом, а не «вшивається» в код
    private final Path avatarDir;

    public AvatarStorageService(@Value("${app.storage.avatar-dir}") Path avatarDir) {
        // Нормалізуємо базовий шлях одразу, щоб потім коректно перевіряти належність
        this.avatarDir = avatarDir.toAbsolutePath().normalize();
    }
}

Тепер сам метод збереження. Ми створюємо каталог, генеруємо імʼя через UUID, обираємо розширення (дуже спрощено) і копіюємо дані.

import java.io.IOException;
import java.nio.file.*;
import java.util.UUID;
import org.springframework.web.multipart.MultipartFile;

public String store(MultipartFile file) {
    try {
        // Гарантуємо, що папка існує, перш ніж копіювати файл
        Files.createDirectories(avatarDir);

        // Обираємо розширення з contentType (спрощено: очікуємо, що валідатор уже відфільтрував типи)
        String ext = "image/png".equals(file.getContentType()) ? ".png" : ".jpg";

        // Генеруємо серверне імʼя: не довіряємо originalFilename і уникаємо колізій
        String name = UUID.randomUUID() + ext;

        // Збираємо шлях строго всередині avatarDir
        Path target = avatarDir.resolve(name).normalize();

        // Навіть із UUID-іменем дотримуємося правила: кінцевий шлях має лишитися всередині avatarDir
        if (!target.startsWith(avatarDir)) {
            throw new SecurityException("Недійсний цільовий шлях");
        }

        // Копіюємо дані; REPLACE_EXISTING — про всяк випадок (UUID майже виключає колізії, але поведінка явна)
        Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

        // У профіль зберігаємо не повний шлях, а «ключ»/імʼя файла
        return name;
    } catch (IOException e) {
        // I/O — це вже серверна помилка, а не «помилка запиту клієнта»
        throw new IllegalStateException("Не вдалося зберегти аватар", e);
    }
}

Цього достатньо для навчального проєкту: імʼя генерує сервер, каталог контролює сервер, розмір обмежує валідатор і налаштування multipart. А ще це зручно тестувати вручну: після успішного виклику кінцевої точки ви бачите новий файл у папці storage.

Щоб завершити картину, покажемо мінімальні налаштування в application.yml, які роблять storage і multipart-ліміти явними:

app:
  storage:
    # Куди складаємо аватари на диску (для dev-режиму можна зберігати поруч із проєктом)
    avatar-dir: ./storage/avatars

spring:
  servlet:
    multipart:
      # Ліміт на рівні multipart: «захист від великих запитів» перед вашою бізнес-логікою
      max-file-size: 2MB
      max-request-size: 3MB

Так, Spring Boot і так має значення за замовчуванням, але явне налаштування допомагає навчанню: студент розуміє, де «налаштовується безпека за розміром», і чому це взагалі існує.

8. Профіль: оновлюємо лише посилання

Коли ви кажете «завантажити аватар», легко уявити, що ми «кладемо картинку в профіль». Але в backend-мисленні, і особливо в цьому курсі, ми тримаємо правило: профіль зберігає не бінарні дані, а посилання або шлях. Інакше ви почнете тягати важкі байти через DTO, репозиторії, JSON і решту місць, де вони не потрібні.

У нашому навчальному застосунку профіль може зберігатися в пам’яті (Map) — нам зараз важливе не постійне зберігання, а безпека і коректний потік. Покажемо невеликий сервісний метод, який змінює avatarPath у користувача:

package com.example.securecontent.profile;

import org.springframework.stereotype.Service;

@Service
public class UserProfileService {

    private final UserProfileRepository repo;

    public UserProfileService(UserProfileRepository repo) {
        this.repo = repo;
    }

    public void setAvatarPath(String username, String avatarPath) {
        // Завантажуємо (або створюємо) профіль для поточного користувача
        UserProfile profile = repo.getOrCreate(username);

        // Змінюємо лише посилання/шлях: бінарні дані в профілі не зберігаємо
        profile.setAvatarPath(avatarPath);

        // Зберігаємо оновлений стан
        repo.save(username, profile);
    }
}

Тут ми не обговорюємо, як правильно зберігати в БД, — це буде пізніше, в іншому модулі. Зараз нам важливо побачити принцип: завантаження змінює стан профілю, і цей стан змінюється лише після успішного збереження файла. Тому setAvatarPath іде останнім кроком у AvatarService.

9. Помилки і статуси: відповідальність шарів

Дуже часта плутанина у новачків виглядає так: «Чому під час завантаження в мене то 403, то 400, то 413? Я ж просто файл відправив». Це якраз той випадок, коли «різні помилки» — це добре. Вони означають, що різні шари роблять свою роботу.

Зберемо в таблицю, щоб було простіше не гадати:

Ситуація Де «ламається» запит Типовий HTTP-статус Що це означає простими словами
Користувач не увійшов Security filter chain 302/401/403 (залежить від клієнта і налаштувань) «Спочатку увійдіть»
Немає або неправильний CSRF token Security filter chain 403 «Запит схожий на той, що змінює стан, але токен не підтверджено»
Файл не прикріпили (isEmpty) Validator / сервіс 400 «Файл обов’язковий»
Тип файла не з білого списку Validator / сервіс 400 «Ми не приймаємо такий тип»
Файл завеликий multipart / validator 413 або 400 «Занадто великий запит»
Не вдалося зберегти файл (I/O) storage service 500 «Проблема на сервері або диску»

Найкорисніше відчуття, яке потрібно зловити: security і multipart — це не «два випадкові перешкоди». Це два шари однієї системи. Security вирішує, чи взагалі можна виконувати операцію, що змінює стан, а прикладна логіка вирішує, чи можна приймати конкретний файл.

І так, у цей момент ви починаєте любити тонкі контролери: тому що логіку можна тестувати й налагоджувати на рівні сервісів, не влаштовуючи археологічних розкопок в одному монструозному методі контролера.

10. Типові помилки під час збирання POST /api/me/avatar

Помилка №1: «Контролер-комбайн».
Новачок робить один метод на 60 рядків, де перемішано: читання MultipartFile, перевірка ролей, ручна перевірка CSRF, генерація імені, збереження на диск і оновлення профілю. Такий код складно читати і майже неможливо нормально тестувати. Лікується просто: контролер має приймати дані і викликати один сервісний метод, а деталі переїжджають у validator і storage.

Помилка №2: збереження файла до валідації.
Це дуже поширена логічна помилка: «збережу файл, а потім перевірю, чи він підходить». Якщо перевірка не пройшла, ви вже зберегли сміття. У результаті storage розростається, а поведінка стає нечесною: клієнт отримав 400, але сервер уже прийняв і записав дані. Правильний порядок: спочатку перевірки, потім запис на диск.

Помилка №3: оновлення профілю до успішного збереження.
Іноді код виглядає так: «поставили avatarPath, потім намагаємося зберегти файл». Якщо Files.copy(...) упаде, профіль почне посилатися на файл, якого немає. У продакшені це перетворюється на «биті аватарки» і загадкові баги. Правило просте: профіль змінюємо лише після успішного store().

Помилка №4: довіра contentType як абсолютній істині.
file.getContentType() — корисний сигнал, але він прийшов від клієнта. Якщо сприймати його як «гарантію», то ви будуєте безпеку на чесності клієнта, а це, м’яко кажучи, оптимізм. У нашому курсі ми використовуємо contentType як перший фільтр і обмежуємося білим списком, але пам’ятаємо межі цього підходу.

Помилка №5: використовувати originalFilename як імʼя файла на сервері.
Навіть якщо імʼя виглядає мило, воно все одно клієнтське. Воно може конфліктувати з іншими файлами, містити дивні символи і заважати вам керувати storage. Набагато безпечніше генерувати імʼя сервером (UUID) і зберігати в профілі лише результат.

1
Опитування
Завантаження аватара, рівень 13, лекція 4
Недоступний
Завантаження аватара
Безпечна робота з файлами
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ