How it works
The full technical description: application layers, request lifecycle, data model, two-phase pagination and caching, the SEO pipeline, security and aspect-based auditing.
This document answers the question "why is it built this way". The sections run top-down through the stack: from a map of the layers to the individual mechanisms.
Layers#
A classic three-layer Spring arrangement with no violations in either direction: controllers never touch repositories, repositories know nothing about HTTP.
The controllers are split by access level, not by entity. That is a
deliberate choice: the permission check is declared once per class
(@PreAuthorize on StaffController and AdminController), which makes it hard
to add a new endpoint "in the wrong place" — it simply lands in a class that
already carries the right annotation.
The request lifecycle#
Between the socket and the HTML page a request passes four points that matter.
Both custom filters sit before the password authentication filter, and that matters:
ProcessLoginRateFilterhas to cut off password guessing before the password is checked. Otherwise every attempt would cost a BCrypt computation — and BCrypt is deliberately slow, so brute forcing would turn into a denial-of-service;SiteProtectionFilternevertheless recognises a signed-in staff member even though it runs before authentication. That works because the security context has already been restored from the session by an earlier Spring Security filter by the time it is invoked. The "our people always pass" rule only breaks down for the login request itself — and that one is let through by the allow-list.
InfoController is not really a controller but a @ControllerAdvice with a
@ModelAttribute. It runs before every handler and fills the model with the data
that literally every page needs: the footer, the contacts, the banner, the
organization name, the JSON-LD. Thanks to that, no method begins with ten lines
of "add the same things to the model as everywhere else".
The data model#
Six tables plus two join tables. The schema is created by Hibernate from the annotations.
Three decisions in this schema deserve an explanation.
An author is not a user. content_authors and users are unrelated. An
author is a byline: a child from the group or a teacher, neither of whom has or
should have access to the panel. Who actually published the material is recorded
in a separate published_by field. Merging the two entities would be tempting,
but then every author would need an account.
The album's file list is not stored in the database. media_collections
holds only batchId — a directory name. The contents of the gallery are read
from disk every time the page opens. The upside: files can be added or removed
straight in the filesystem without the database drifting out of sync, and "in
the database but not on disk" becomes impossible. The downside: every album view
costs a directory listing, and the order of the photos can only be controlled
through file names.
The indexes are built for one specific query.
(type, creation_timestamp DESC) on both content tables is exactly what the
feeds need — filter by type, sort by date — which is to say the front page,
/records and /media.
Two-phase pagination#
The most interesting part of the project. The task: render a page of posts
together with their authors and tags, with spring.jpa.open-in-view=false (that
is, with the Hibernate session closed before the template is rendered).
The obvious solution — findAll(Pageable) with a JOIN FETCH — does not work:
when a query joins a collection, Hibernate cannot limit the result set on the
SQL side and pulls all rows in order to pick the right ones in memory. With a
hundred posts nobody notices; with ten thousand it is a disaster.
Here it is done differently:
Phase 1 is a query returning nothing but id: pagination happens entirely in
SQL and no collections are involved. Phase 2 fetches each entity by primary key
through ContentService, where @Cacheable sits. The author and the tags are
pulled in via @EntityGraph, that is, in a single query.
The gain is not limited to the first render. The same post appears on the front page, in the feed, on the author's page and on the tag page — and in every one of those places it comes from the cache. Filtering by type or moving to another page only means a new list of identifiers, not re-reading the content.
Collections in the entities are declared as Set rather than List. That is not
stylistic: an author loads both posts and media at once, and Hibernate only
tolerates two collection JOIN FETCHes in one query for Set — with lists it
fails with MultipleBagFetchException.
What lives in which cache#
| Cache | Contents | Lifetime |
|---|---|---|
post, medium, author, tag | entities by id | 60 minutes after last access, up to 500 entries |
sitemap | the whole list of sitemap addresses | 60 minutes after being written |
buckets | rate-limit counters | 24 hours after last access, up to 10,000 |
The first row is configured through app.cache.lifetime and
app.cache.maxsize; the rest are set in code. Caches are evicted on edits made
through the panel (surgically, for the changed record) and wholesale via the
"Clear cache" button.
Settings: two different mechanisms#
These are easy to confuse, hence a table.
application.properties | properties.json | |
|---|---|---|
| What it holds | infrastructure: port, database, paths, limits | settings-as-content: SEO, contacts, social links, banner, short links, token |
| Who changes it | the server administrator | the site administrator, from the panel |
| When it applies | at startup | immediately |
| Where it lives | in the jar plus an external file | in the process's working directory |
PropertiesService keeps all the settings in fields on the object, and the
JSON file is a mirror on disk. Any change through the panel updates a field and
immediately rewrites the whole file. The file is read exactly once, at startup.
Two consequences follow. First: editing properties.json by hand while the
application is running is pointless — the in-memory state will overwrite the
file on the next change. Second: if the file is missing, sensible defaults are
created on first startup, including SEO for every page, so the site is
functional right away.
Осторожно
Reading the JSON does not check that keys are present. Remove, say, orgName
from the file and the application will not start: initialization dies
dereferencing the missing node. Only edit the file while the application is
stopped, and keep a copy.
The SEO pipeline#
The densest part of the project. The goal: every page, dynamic ones included,
should have a meaningful title, a description, Open Graph tags and correct
Schema.org markup with breadcrumbs.
Three ideas hold this together.
SEO is data, not code. Titles live in the JSON file and are edited from the panel. Adding a new page to SEO means adding a record, not writing a method.
Breadcrumbs are derived from a graph. Every record carries a reference to its
parent page. The service walks up that chain until it reaches the root and
assembles the BreadcrumbList itself. There is no need to spell out breadcrumbs
on each page — naming the parent correctly is enough.
The escaping is done right. JSON-LD is emitted inside
<script type="application/ld+json"> without HTML escaping — otherwise the
markup would be invalid. So before output the characters <, > and the
ampersand are replaced with Unicode escape sequences: to a JSON parser they are
equivalent, but you cannot close a script tag with them. This is exactly the
case where "just escape the HTML" would be a bug and "emit it as-is" would be a
hole.
sitemap.xml is assembled by the same layer: static addresses are listed in
code, dynamic ones come from lightweight queries that pull only the id, the
date and the preview out of the database — the entities themselves are not
needed for a sitemap. For albums the cover image is additionally included via
the sitemap-image extension.
Security#
Authentication#
The login form is at /auth, processing at /process_login, passwords are
BCrypt with a cost factor of 10. The role is stored as a string (ROLE_ADMIN,
ROLE_MODERATOR) and handed to Spring Security as the user's single authority.
The first administrator is created at startup if the table contains no user with
the ROLE_ADMIN role, taking the login and password from security.admin.*. The
mechanism solves the chicken-and-egg problem exactly once and then stays out of
the way.
CSRF protection is on (the Spring Security default, not disabled here). The token
is embedded into the panel page as a hidden field, and the client-side wrapper
around fetch puts it into the X-CSRF-TOKEN header on every mutating request.
Three levels of permission checks#
- By route.
/dashboard/**and/actuator/**require the administrator or moderator role; everything else is open. - By controller class.
@PreAuthorizeonStaffController(both roles) and onAdminController(administrator only). - By the meaning of the operation. Refusing to delete the last administrator, for instance, is a business rule inside the service.
The overlap between the first two levels is intentional: the route rule is a coarse net, the class annotation is the guarantee that a new method will not end up open through an oversight.
Rate limiting#
Two independent mechanisms on top of one Bucket4j implementation, with the counters living in a Caffeine cache.
The login filter checks three limits at once:
| Limit | Key | Purpose |
|---|---|---|
| 5 per minute | client address | guessing from a single address |
| 5 per minute | the submitted login | distributed guessing against one account |
| 30 per day | client address | slow guessing that stays under the per-minute limits |
Exceeding any of them returns 429 with a JSON message immediately, before the password is checked.
The @RateLimit annotation is for any other method. The key defaults to the
client address, but it can be given as a SpEL expression over the method
arguments — to limit actions per object id, for example. The annotation is
repeatable: several different limits can be stacked on one method.
XSS protection in posts#
The trust model here is unusual and worth understanding in full.
The body of a post is stored as Markdown and turned into HTML in the reader's
browser by marked.js. The server serves the original text. That means the
author of a post can effectively insert arbitrary HTML — and the defence against
that is also client-side: before being inserted into the document, the markup
goes through a sanitizer that removes dangerous tags (script, iframe,
object, form, svg and others), event handlers, and links using the
javascript: and data: schemes.
The second line of defence is the content security policy at the header level. It constrains where scripts may be loaded from at all and what may be embedded in a frame: YouTube, Rutube, Instagram, Telegram and MAX are permitted.
The sanitizer can be switched off by a flag in the settings. Formally this exists "for testing script functionality", and the code comment says outright that this is where the trusted zone begins.
Внимание
Client-side sanitization protects against accidental damage but fundamentally cannot protect against deliberate damage: it runs on the same side as the attack. The real boundary here is trust in the people who have panel access, plus the content security policy. Keep protection enabled, and give moderators one account per person rather than one shared account.
Site protection mode#
A dedicated filter closes off the public part while the site is being prepared
for launch. Those who pass: staff, service addresses (login, the panel, the error
page, robots.txt, the icons, the lock screen itself) and anyone whose session
carries the access marker. The marker is set on the first visit through a link
with the correct token, after which a redirect to the same address without the
parameter follows — so the token stays out of browser history, out of the
referrer and off other people's screenshots.
Aspect-based auditing#
Logging is not smeared across the services but pulled out into a single aspect.
@PostMapping("/posts/create")
@AuditAction("Create post")
public ResponseEntity<Post> create(@AuthenticationPrincipal(...) Person user,
@RequestBody PostDTO dto) { ... }
The aspect intercepts the call, waits for it to finish and writes to the log: the user name from the security context, a human-readable description of the action, the method name and the arguments.
Arguments are handled carefully:
- a parameter or record component marked
@AuditIgnorelands in the log as******— this is how passwords and post bodies are hidden; - Java
records are broken down into components, so the log showsPostDTO[id=5, title=Summer trip, ...]rather than an object's memory address; - the user object is not expanded — a placeholder is written instead.
Logins are logged differently: by a Spring Security event listener, with
@Async, so that writing to the database does not delay the response. Both
successful and failed attempts are recorded, the latter along with the reason for
the refusal.
The value of this split is that it extends in one line: to get a new operation into the log, add the annotation.
File storage#
All the files of one album live in a directory named after its batchId. The
conventions are simple:
- the name
preview.jpgis reserved for the cover. A file uploaded as the cover is always renamed to it; - the cover is excluded from the gallery when listing;
- when a file is deleted, the directory is checked for emptiness and removed if empty;
- deleting an album recursively wipes the whole directory.
Error handling#
Three handlers for three situations:
| Situation | What happens |
|---|---|
| An error on a site page | an exception carrying a code and a message is caught and turned into a styled error page |
| An error in a panel REST request | a separate exception type, serialized to JSON with the code, message, timestamp and path |
| An error outside the controllers | the standard /error entry point, where a human explanation is picked by status code |
Validation errors are collected into a "field → message" map and returned in a single response, so that the form in the panel can highlight every problem field at once.