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.

controllers MainController public pages StaffController content, both roles AdminController settings, admin only InfoController shared data for every page services ContentService read by id + cache PageService pagination StaffService writes, cache eviction StorageService files on disk PropertiesService settings in memory, mirrored to JSON SeoService meta tags, JSON-LD, breadcrumbs aspects: audit and limits cutting across all layers repositories Spring Data JPA: Post, MediaCollection, ContentAuthor, ContentTag, Person, ActionLog PropertiesRepository PostgreSQL all content and the log files photos and video properties.json settings and SEO

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.

HTTP request from nginx context from the session who this is, if already signed in CSRF check ProcessLoginRateFilter login requests only over the limit — instant 429 SiteProtection token or staff otherwise the lock screen route authorization: /dashboard and /actuator are staff-only everything else is open InfoController — @ControllerAdvice, runs before every controller host, organization name, contacts, social links, banner, JSON-LD, static-page SEO the controller method, wrapped in aspects a Thymeleaf template, or JSON in the response

Both custom filters sit before the password authentication filter, and that matters:

  • ProcessLoginRateFilter has 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;
  • SiteProtectionFilter nevertheless 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.

content_authors id name about cascading delete! 1 : N 1 : N posts id, title introduction — the teaser content — markdown, TEXT type: NEWS|STORY|ANNOUNCEMENT published_by — who pressed it creation_timestamp index (type, date DESC) media_collections id, title path — batchId, UNIQUE preview_path type: PHOTO|VIDEO published_by creation_timestamp the file list is not here N : M N : M content_tags id, name UNIQUE 2..30 description via post_tags_map / collection_tags_map users login UNIQUE password — BCrypt role, name action_logs username, action method_name, details timestamp panel staff the log, linked to nothing

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 a page of identifiers only SELECT id ... LIMIT 6 OFFSET n Phase 2 each entity by id cache first, database second finished objects with authors and tags Caffeine caches: post, medium, author, tag entries live 60 minutes after last access, up to 500 per cache A cache miss costs one fetch-join query on the primary key — a cheap operation. A hit costs nothing at all.

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#

CacheContentsLifetime
post, medium, author, tagentities by id60 minutes after last access, up to 500 entries
sitemapthe whole list of sitemap addresses60 minutes after being written
bucketsrate-limit counters24 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.propertiesproperties.json
What it holdsinfrastructure: port, database, paths, limitssettings-as-content: SEO, contacts, social links, banner, short links, token
Who changes itthe server administratorthe site administrator, from the panel
When it appliesat startupimmediately
Where it livesin the jar plus an external filein 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.

properties.json per address: title, description, schema type, parent page Step 1 — for every page WebSite + Organization: name, contacts, social links placed in the model as an array Step 2a — an ordinary page the address matched a key — SEO is filled in automatically, the controller does nothing Step 2b — a dynamic page the controller passes the item data, placeholders in the template are replaced breadcrumbs follow the parent chain Step 3 — the page schema is appended to the same array characters that are dangerous inside a script tag become escape sequences the template emits the finished JSON-LD as one block

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#

  1. By route. /dashboard/** and /actuator/** require the administrator or moderator role; everything else is open.
  2. By controller class. @PreAuthorize on StaffController (both roles) and on AdminController (administrator only).
  3. 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:

LimitKeyPurpose
5 per minuteclient addressguessing from a single address
5 per minutethe submitted logindistributed guessing against one account
30 per dayclient addressslow 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 @AuditIgnore lands in the log as ****** — this is how passwords and post bodies are hidden;
  • Java records are broken down into components, so the log shows PostDTO[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.jpg is 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:

SituationWhat happens
An error on a site pagean exception carrying a code and a message is caught and turned into a styled error page
An error in a panel REST requesta separate exception type, serialized to JSON with the code, message, timestamp and path
An error outside the controllersthe 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.