Как это работает
Полное техническое описание: слои приложения, жизненный цикл запроса, модель данных, двухфазная пагинация и кеш, конвейер SEO, безопасность, аудит на аспектах и честный список слабых мест.
Документ отвечает на вопрос «почему сделано именно так». Порядок разделов — сверху вниз по стеку: от карты слоёв к отдельным механизмам.
Слои#
Классическая трёхслойная схема Spring, без нарушений в обе стороны: контроллеры не трогают репозитории, репозитории не знают про HTTP.
Разделение контроллеров сделано по уровню доступа, а не по сущностям. Это
осознанный выбор: проверка прав объявлена один раз на класс
(@PreAuthorize над StaffController и AdminController), и добавить новый
эндпоинт «не туда» становится трудно — он просто окажется в классе с нужной
аннотацией.
Жизненный цикл запроса#
Между сокетом и HTML-страницей запрос проходит четыре важные точки.
Оба собственных фильтра стоят до фильтра аутентификации по паролю, и это существенно:
ProcessLoginRateFilterдолжен отсечь перебор паролей раньше, чем начнётся проверка пароля. Иначе каждая попытка стоила бы вычисления BCrypt — а он специально сделан медленным, и подбор превратился бы в отказ в обслуживании;SiteProtectionFilterпри этом умеет узнавать вошедшего сотрудника, хотя и стоит до аутентификации. Работает это потому, что контекст безопасности к моменту его вызова уже восстановлен из сессии более ранним фильтром Spring Security. Логика «свои проходят всегда» ломается только для самого запроса на вход — и его как раз пускает список исключений.
InfoController — не совсем контроллер, а @ControllerAdvice с
@ModelAttribute. Он выполняется перед каждым обработчиком и наполняет модель
данными, которые нужны буквально всем страницам: подвал, контакты, баннер,
название организации, JSON-LD. Благодаря этому ни один метод не начинается с
десяти строк «добавить в модель то же, что везде».
Модель данных#
Шесть таблиц и две связующие. Схема создаётся Hibernate по аннотациям.
Три решения в этой схеме заслуживают объяснения.
Автор — не пользователь. content_authors и users не связаны. Автор — это
подпись под материалом: ребёнок из отряда или преподаватель, у которых нет и не
должно быть доступа к панели. Кто фактически опубликовал материал, пишется
отдельным полем published_by. Смешать эти сущности было бы соблазнительно, но
тогда каждому автору пришлось бы завести учётную запись.
Список файлов альбома в базе не хранится. В media_collections есть только
batchId — имя каталога. Состав галереи читается с диска при каждом открытии
страницы. Плюс: файлы можно добавить или убрать прямо в файловой системе, база
не рассинхронизируется, невозможна ситуация «в базе есть, на диске нет». Минус:
на каждый показ альбома делается листинг каталога, и порядком снимков можно
управлять только именами файлов.
Индексы поставлены под конкретный запрос. (type, creation_timestamp DESC)
на обеих контентных таблицах — ровно то, что нужно для лент с фильтром по типу
и сортировкой по дате, то есть для главной страницы, /records и /media.
Двухфазная пагинация#
Самое интересное место в проекте. Задача: показать страницу постов вместе с
авторами и тегами, при spring.jpa.open-in-view=false (то есть сессия Hibernate
закрыта до момента рендеринга шаблона).
Прямое решение — findAll(Pageable) с JOIN FETCH — не работает: при
join-запросе к коллекции Hibernate не может ограничить выборку на стороне SQL и
вытаскивает все строки, чтобы отобрать нужные в памяти. На сотне постов это
незаметно, на десяти тысячах — катастрофа.
Здесь сделано иначе:
Фаза 1 — запрос, возвращающий только id: пагинация полностью выполняется в
SQL, коллекции не участвуют. Фаза 2 — получение каждой сущности по
первичному ключу через ContentService, где стоит @Cacheable. Автор и теги
подтягиваются через @EntityGraph, то есть одним запросом.
Выигрыш не только в первом показе. Один и тот же пост попадает в главную страницу, в ленту, на страницу автора и на страницу тега — и во всех этих местах он берётся из кеша. Фильтр по типу или переход на другую страницу означает лишь новый список идентификаторов, а не повторное чтение содержимого.
Коллекции в сущностях объявлены как Set, а не List. Это не стилистика: у
автора одновременно подгружаются и посты, и медиа, а два JOIN FETCH по
коллекциям в одном запросе Hibernate допускает только для Set — со списками
он падает с MultipleBagFetchException.
Что в каком кеше лежит#
| Кеш | Содержимое | Время жизни |
|---|---|---|
post, medium, author, tag | сущности по id | 60 минут с последнего обращения, до 500 записей |
sitemap | весь список адресов для карты сайта | 60 минут с момента записи |
buckets | счётчики лимитов запросов | 24 часа с последнего обращения, до 10 000 |
Первая строка настраивается через app.cache.lifetime и app.cache.maxsize,
остальные заданы в коде. Сброс кеша — при правках через панель (точечно, по
изменённой записи) и целиком по кнопке «Очистить кеш».
Настройки: два разных механизма#
Легко запутаться, поэтому таблица.
application.properties | properties.json | |
|---|---|---|
| Что хранит | инфраструктуру: порт, база, пути, лимиты | контент настроек: SEO, контакты, соцсети, баннер, короткие ссылки, токен |
| Кто меняет | администратор сервера | администратор сайта из панели |
| Когда применяется | при старте | немедленно |
| Где лежит | в jar плюс внешний файл | в рабочем каталоге процесса |
PropertiesService держит все настройки в полях объекта, а JSON — это
зеркало на диске. Любое изменение через панель правит поле и тут же
перезаписывает файл целиком. Читается файл ровно один раз, при старте.
Отсюда два следствия. Первое: правка properties.json руками при работающем
приложении бесполезна — состояние в памяти перепишет файл при следующем
изменении. Второе: если файла нет, при первом старте создаются осмысленные
значения по умолчанию, включая SEO для всех страниц — сайт работоспособен
сразу.
Осторожно
Чтение JSON не проверяет наличие ключей. Если удалить из файла, например,
orgName, приложение не запустится: инициализация упадёт на разыменовании
отсутствующего узла. Правьте файл только при остановленном приложении и
сохраняйте копию.
Конвейер SEO#
Самая насыщенная часть проекта. Задача — чтобы у каждой страницы, включая
динамические, были осмысленный title, описание, Open Graph и корректная
разметка Schema.org с «хлебными крошками».
Три идеи, на которых это держится.
SEO — данные, а не код. Заголовки лежат в JSON и правятся из панели. Добавить новую страницу в SEO — значит добавить запись, а не написать метод.
Хлебные крошки выводятся из графа. У каждой записи есть ссылка на
родительскую страницу. Сервис идёт по этой цепочке вверх, пока не дойдёт до
корня, и собирает BreadcrumbList сам. Прописывать крошки на каждой странице
руками не нужно — достаточно правильно указать родителя.
Экранирование сделано правильно. JSON-LD выводится внутрь
<script type="application/ld+json"> без HTML-экранирования — иначе разметка
стала бы невалидной. Поэтому перед выводом символы <, > и амперсанд
заменяются на юникодные escape-последовательности: для JSON-парсера они
эквивалентны, а закрыть тег script через них невозможно. Это ровно тот
случай, где «просто заэкранировать HTML» было бы ошибкой, а «вывести как есть» —
уязвимостью.
sitemap.xml собирается тем же слоем: статические адреса перечислены в коде,
динамические берутся облегчёнными запросами, которые тянут из базы только id,
дату и превью — сами сущности для карты сайта не нужны. Для альбомов в карту
дополнительно попадает обложка через расширение sitemap-image.
Безопасность#
Аутентификация#
Форма входа на /auth, обработка на /process_login, пароли — BCrypt с
фактором сложности 10. Роль хранится строкой (ROLE_ADMIN, ROLE_MODERATOR) и
отдаётся Spring Security как единственная привилегия пользователя.
Первый администратор создаётся при старте, если в таблице нет ни одного
пользователя с ролью ROLE_ADMIN, — логин и пароль берутся из
security.admin.*. Механизм ровно один раз решает проблему «курицы и яйца» и
дальше не вмешивается.
CSRF-защита включена (значение по умолчанию Spring Security и здесь не
отключено). Токен вставляется в страницу панели скрытым полем, а клиентская
обёртка над fetch подставляет его в заголовок X-CSRF-TOKEN при каждом
изменяющем запросе.
Три уровня проверки прав#
- По маршруту.
/dashboard/**и/actuator/**требуют роль администратора или модератора, остальное открыто. - По классу контроллера.
@PreAuthorizeнадStaffController(обе роли) и надAdminController(только администратор). - По смыслу операции. Например, запрет удалить последнего администратора — это уже бизнес-правило внутри сервиса.
Дублирование первого и второго уровня намеренное: маршрутное правило — грубая сетка, аннотация на классе — гарантия, что новый метод не окажется открытым по недосмотру.
Ограничение частоты запросов#
Два независимых механизма поверх одной реализации на Bucket4j, где счётчики живут в кеше Caffeine.
Фильтр для входа проверяет три лимита одновременно:
| Лимит | Ключ | Зачем |
|---|---|---|
| 5 в минуту | адрес клиента | перебор с одного адреса |
| 5 в минуту | введённый логин | распределённый перебор одной учётной записи |
| 30 в сутки | адрес клиента | медленный перебор в обход минутных лимитов |
При превышении сразу возвращается 429 с JSON-сообщением, до проверки пароля.
Аннотация @RateLimit — для любого другого метода. Ключ по умолчанию —
адрес клиента, но его можно задать SpEL-выражением по аргументам метода,
например ограничить действия по идентификатору объекта. Аннотация повторяемая:
на один метод можно навесить несколько разных лимитов.
Защита от XSS в постах#
Модель доверия здесь нестандартная и её стоит понять целиком.
Текст поста хранится как Markdown и превращается в HTML в браузере
читателя библиотекой marked.js. Сервер отдаёт исходный текст. Это значит,
что автор поста фактически может вставить произвольный HTML — и защита от этого
тоже клиентская: перед вставкой в документ разметка проходит через санитайзер,
который удаляет опасные теги (script, iframe, object, form, svg и
другие), обработчики событий и ссылки со схемами javascript: и data:.
Вторая линия обороны — политика безопасности контента на уровне заголовков. Она ограничивает, откуда вообще можно загружать скрипты и что можно встраивать во фрейм: разрешены YouTube, Rutube, Instagram, Telegram и MAX.
Санитайзер можно отключить флагом в настройках. Формально это сделано «для тестирования функциональности скриптов», и в комментарии к коду прямо написано, что там начинается зона доверия.
Внимание
Клиентская санитизация защищает от случайного вреда, но принципиально не может защитить от намеренного: она выполняется на той же стороне, что и атака. Реальную границу здесь держит доверие к тем, у кого есть доступ в панель, плюс политика безопасности контента. Держите защиту включённой, а учётные записи модераторов — на счёт каждого человека, а не одну общую.
Режим защиты сайта#
Отдельный фильтр закрывает публичную часть, пока сайт готовят к запуску.
Проходят: сотрудники, служебные адреса (вход, панель, страница ошибки,
robots.txt, иконки, сама заглушка) и все, у кого в сессии стоит отметка о
доступе. Отметка ставится при первом визите по ссылке с верным токеном, после
чего происходит редирект на тот же адрес без параметра — токен не остаётся ни
в истории браузера, ни в реферере, ни на чужом скриншоте.
Аудит на аспектах#
Журналирование не размазано по сервисам, а вынесено в один аспект.
@PostMapping("/posts/create")
@AuditAction("Создание поста")
public ResponseEntity<Post> create(@AuthenticationPrincipal(...) Person user,
@RequestBody PostDTO dto) { ... }
Аспект перехватывает вызов, дожидается его завершения и пишет в журнал: имя пользователя из контекста безопасности, человекочитаемое описание действия, имя метода и аргументы.
Аргументы обрабатываются аккуратно:
- параметр или поле записи, помеченные
@AuditIgnore, попадают в журнал как******— так скрываются пароли и текст постов; - Java-записи (
record) разбираются по компонентам, поэтому в журнале видноPostDTO[id=5, title=Поездка, ...], а не адрес объекта в памяти; - объект пользователя не разворачивается — вместо него пишется пометка.
Входы в систему логируются иначе: слушателем событий Spring Security, с
@Async, чтобы запись в базу не задерживала ответ. Пишутся и успешные, и
неудачные попытки — вторые вместе с причиной отказа.
Такое разделение полезно тем, что расширяется одной строкой: чтобы новая операция попала в журнал, достаточно навесить аннотацию.
Хранилище файлов#
Все файлы одного альбома лежат в каталоге, названном по batchId.
Соглашения простые:
- имя
preview.jpgзарезервировано под обложку. Файл, загруженный как превью, всегда переименовывается в него; - обложка исключается из галереи при листинге;
- при удалении файла проверяется, не опустел ли каталог, и пустой удаляется;
- удаление альбома рекурсивно стирает каталог целиком.
Обработка ошибок#
Три обработчика на три ситуации:
| Ситуация | Что происходит |
|---|---|
| Ошибка на странице сайта | исключение с кодом и текстом, ловится и превращается в оформленную страницу ошибки |
| Ошибка в REST-запросе панели | отдельный тип исключения, сериализуется в JSON с кодом, сообщением, временем и путём |
| Ошибка вне контроллеров | стандартная точка /error, где по коду подбирается человеческое объяснение |
Ошибки валидации собираются в карту «поле — сообщение» и отдаются одним ответом, чтобы форма в панели могла подсветить все проблемные поля сразу.