Как это работает

Полное техническое описание: слои приложения, жизненный цикл запроса, модель данных, двухфазная пагинация и кеш, конвейер SEO, безопасность, аудит на аспектах и честный список слабых мест.

Документ отвечает на вопрос «почему сделано именно так». Порядок разделов — сверху вниз по стеку: от карты слоёв к отдельным механизмам.

Слои#

Классическая трёхслойная схема Spring, без нарушений в обе стороны: контроллеры не трогают репозитории, репозитории не знают про HTTP.

контроллеры MainController публичные страницы StaffController контент, 2 роли AdminController настройки, только админ InfoController общие данные для всех страниц сервисы ContentService чтение по id + кеш PageService пагинация StaffService запись, сброс кеша StorageService файлы на диске PropertiesService настройки сайта в памяти, зеркалятся в JSON SeoService meta-теги, JSON-LD, хлебные крошки аспекты: аудит и лимиты поперёк всех слоёв репозитории Spring Data JPA: Post, MediaCollection, ContentAuthor, ContentTag, Person, ActionLog PropertiesRepository PostgreSQL весь контент и журнал файлы фото и видео properties.json настройки и SEO

Разделение контроллеров сделано по уровню доступа, а не по сущностям. Это осознанный выбор: проверка прав объявлена один раз на класс (@PreAuthorize над StaffController и AdminController), и добавить новый эндпоинт «не туда» становится трудно — он просто окажется в классе с нужной аннотацией.

Жизненный цикл запроса#

Между сокетом и HTML-страницей запрос проходит четыре важные точки.

HTTP-запрос от nginx контекст из сессии кто это, если уже входил проверка CSRF ProcessLoginRateFilter только для входа превышение — сразу 429 SiteProtection токен или сотрудник иначе — заглушка авторизация маршрута: /dashboard и /actuator — только сотрудникам остальное открыто всем InfoController — @ControllerAdvice, срабатывает перед каждым контроллером host, название организации, контакты, соцсети, баннер, JSON-LD, SEO статических страниц метод контроллера, вокруг него — аспекты шаблон Thymeleaf или JSON в ответ

Оба собственных фильтра стоят до фильтра аутентификации по паролю, и это существенно:

  • ProcessLoginRateFilter должен отсечь перебор паролей раньше, чем начнётся проверка пароля. Иначе каждая попытка стоила бы вычисления BCrypt — а он специально сделан медленным, и подбор превратился бы в отказ в обслуживании;
  • SiteProtectionFilter при этом умеет узнавать вошедшего сотрудника, хотя и стоит до аутентификации. Работает это потому, что контекст безопасности к моменту его вызова уже восстановлен из сессии более ранним фильтром Spring Security. Логика «свои проходят всегда» ломается только для самого запроса на вход — и его как раз пускает список исключений.

InfoController — не совсем контроллер, а @ControllerAdvice с @ModelAttribute. Он выполняется перед каждым обработчиком и наполняет модель данными, которые нужны буквально всем страницам: подвал, контакты, баннер, название организации, JSON-LD. Благодаря этому ни один метод не начинается с десяти строк «добавить в модель то же, что везде».

Модель данных#

Шесть таблиц и две связующие. Схема создаётся Hibernate по аннотациям.

content_authors id name about удаление каскадом! 1 : N 1 : N posts id, title introduction — анонс content — markdown, TEXT type: NEWS|STORY|ANNOUNCEMENT published_by — кто нажал creation_timestamp индекс (type, дата DESC) media_collections id, title path — batchId, UNIQUE preview_path type: PHOTO|VIDEO published_by creation_timestamp список файлов — не здесь N : M N : M content_tags id, name UNIQUE 2..30 description через post_tags_map / collection_tags_map users login UNIQUE password — BCrypt role, name action_logs username, action method_name, details timestamp сотрудники панели журнал, ни с чем не связан

Три решения в этой схеме заслуживают объяснения.

Автор — не пользователь. 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 страница только идентификаторов SELECT id ... LIMIT 6 OFFSET n Фаза 2 каждая сущность по id сначала кеш, потом база готовые объекты с авторами и тегами кеш Caffeine: post, medium, author, tag запись живёт 60 минут с последнего обращения, до 500 записей на кеш Промах по кешу стоит одного запроса с fetch join по первичному ключу — дешёвая операция. Попадание не стоит ничего.

Фаза 1 — запрос, возвращающий только id: пагинация полностью выполняется в SQL, коллекции не участвуют. Фаза 2 — получение каждой сущности по первичному ключу через ContentService, где стоит @Cacheable. Автор и теги подтягиваются через @EntityGraph, то есть одним запросом.

Выигрыш не только в первом показе. Один и тот же пост попадает в главную страницу, в ленту, на страницу автора и на страницу тега — и во всех этих местах он берётся из кеша. Фильтр по типу или переход на другую страницу означает лишь новый список идентификаторов, а не повторное чтение содержимого.

Коллекции в сущностях объявлены как Set, а не List. Это не стилистика: у автора одновременно подгружаются и посты, и медиа, а два JOIN FETCH по коллекциям в одном запросе Hibernate допускает только для Set — со списками он падает с MultipleBagFetchException.

Что в каком кеше лежит#

КешСодержимоеВремя жизни
post, medium, author, tagсущности по id60 минут с последнего обращения, до 500 записей
sitemapвесь список адресов для карты сайта60 минут с момента записи
bucketsсчётчики лимитов запросов24 часа с последнего обращения, до 10 000

Первая строка настраивается через app.cache.lifetime и app.cache.maxsize, остальные заданы в коде. Сброс кеша — при правках через панель (точечно, по изменённой записи) и целиком по кнопке «Очистить кеш».

Настройки: два разных механизма#

Легко запутаться, поэтому таблица.

application.propertiesproperties.json
Что хранитинфраструктуру: порт, база, пути, лимитыконтент настроек: SEO, контакты, соцсети, баннер, короткие ссылки, токен
Кто меняетадминистратор сервераадминистратор сайта из панели
Когда применяетсяпри стартенемедленно
Где лежитв jar плюс внешний файлв рабочем каталоге процесса

PropertiesService держит все настройки в полях объекта, а JSON — это зеркало на диске. Любое изменение через панель правит поле и тут же перезаписывает файл целиком. Читается файл ровно один раз, при старте.

Отсюда два следствия. Первое: правка properties.json руками при работающем приложении бесполезна — состояние в памяти перепишет файл при следующем изменении. Второе: если файла нет, при первом старте создаются осмысленные значения по умолчанию, включая SEO для всех страниц — сайт работоспособен сразу.

Осторожно

Чтение JSON не проверяет наличие ключей. Если удалить из файла, например, orgName, приложение не запустится: инициализация упадёт на разыменовании отсутствующего узла. Правьте файл только при остановленном приложении и сохраняйте копию.

Конвейер SEO#

Самая насыщенная часть проекта. Задача — чтобы у каждой страницы, включая динамические, были осмысленный title, описание, Open Graph и корректная разметка Schema.org с «хлебными крошками».

properties.json для каждого адреса: title, описание, тип схемы, родительская страница Шаг 1 — для всех страниц WebSite + Organization: название, контакты, соцсети кладётся в модель как массив Шаг 2а — обычная страница адрес совпал с ключом — SEO подставляется само, контроллер ничего не делает Шаг 2б — динамическая страница контроллер передаёт данные материала, в шаблоне заменяются подстановки крошки строятся по цепочке родителей Шаг 3 — схема страницы дописывается в тот же массив символы, опасные внутри тега script, заменяются на escape-последовательности шаблон выводит готовый JSON-LD одним блоком

Три идеи, на которых это держится.

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 при каждом изменяющем запросе.

Три уровня проверки прав#

  1. По маршруту. /dashboard/** и /actuator/** требуют роль администратора или модератора, остальное открыто.
  2. По классу контроллера. @PreAuthorize над StaffController (обе роли) и над AdminController (только администратор).
  3. По смыслу операции. Например, запрет удалить последнего администратора — это уже бизнес-правило внутри сервиса.

Дублирование первого и второго уровня намеренное: маршрутное правило — грубая сетка, аннотация на классе — гарантия, что новый метод не окажется открытым по недосмотру.

Ограничение частоты запросов#

Два независимых механизма поверх одной реализации на 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, где по коду подбирается человеческое объяснение

Ошибки валидации собираются в карту «поле — сообщение» и отдаются одним ответом, чтобы форма в панели могла подсветить все проблемные поля сразу.