Перейти до вмісту

Architecture

Deep reference: CLAUDE.md (sections “Routing”, “The admin panel”, “Content-Security-Policy”)

browser ──HTTPS──> nginx (TLS, Let's Encrypt) ──HTTP──> app (Node 20, Express 5) ──> SQLite + uploads
│
├─ BackupScheduler (timer) ──> BACKUP_DIR ──> S3 bucket (optional)
├─ UpdateChecker (timer) ──> GitHub releases API
├─ trash / upload / audit / session purges (timers)
└─ scheduled posts, every 60 s (timer)
  • docker-compose.prod.yml: app (the GHCR image named by APP_IMAGE), nginx (nginx/templates/default.conf.template, SITE_DOMAIN substituted at start) and certbot (renewal loop). DATA_DIR=/data and BACKUP_DIR=/backups are bind mounts outside the code directory.
  • src/server.js is the entry point: loads and validates the environment (src/config/env.js, fail-fast), opens the database (src/db/index.js — WAL, foreign_keys = ON, migrations), builds the services and hands them to src/lib/createServer.js, which calls createApp(...); its start() writes DATA_DIR/.server.lock (so scripts/restore.js can refuse to run under a live server), listens, then starts the timers. On SIGTERM it stops taking connections, lets open requests (≤ 10 s) and a running backup (≤ 8 s) finish, removes the lock and exits; stop() does the same without exiting, for tests.
  • Everything that is not a request (backups, update check, purges, publishing scheduled posts) runs on unref’d timers inside the same process. The admin router’s own chores (purges, the scheduled-post tick of src/admin/lib/publishScheduled.js) run once when it is built and then through safeInterval, so a test app runs them once too — against an empty database. Tests never start them: buildTestApp passes no scheduler/checker unless a test injects a fake.

src/app.js mounts, in this order:

  1. GET /health — before logging, helmet and static files; JSON, no session, no-store.
  2. Request logger (pino-http), helmet() with the site-wide CSP, Permissions-Policy.
  3. GET /theme.css (brand colours from settings), GET /site.css + GET /site.js (the public stylesheets and scripts joined at boot, src/routes/assets.js), then express.static('public'), then /uploads/* from DATA_DIR/uploads.
  4. express.urlencoded (10 kB, skipped for /admin, which parses its own), then createLocals — resolves settings, menu and site identity into res.locals and defines res.renderPage() synchronously, so the error handler can always render.
  5. Public routers: seo (robots.txt, sitemap.xml, /<feed>.rss) → search (/poshuk) → site (fixed pages, then /:slug and /:slug/:child for pages of type gallery, people, posts, which call next() for anything else) → contact (POST /send-message).
  6. /admin — createAdminRouter() (below).
  7. documents — the catch-all GET /:pageName for info pages and GET /:pageName/zvit (the printable school-year list of a page’s posts).
  8. notFoundHandler, errorHandler.

Rendering: a route calls res.renderPage('<view>', data) (src/lib/renderPage.js); the view renders to a string and is wrapped in src/views/layouts/base.ejs (head, top bar, header, menu, footer partials). The admin’s draft previews use the same res.renderPage with noIndex: true.

src/admin/index.js builds an independent chain: express-session over the SQLite sessions table (cookie <SITE_SLUG>.sid, Path=/admin, Secure in production, 12 h rolling, 7 days at most) → flash → renderAdminPage → urlencoded (1 MB, 413 page) → CSRF (double submit; an upload route is built with multipartRoute() and registered by mount(), and verifies the token after multer — src/admin/lib/multipart.js) → auth router (login/logout, rate limited) → setup wizard (/admin/setup, only while the users table is empty) → requireAuth (re-reads the user every request) → can() exposed to views → audit middleware → cache invalidation after every non-GET → feature routers → admin 404/500.

Conventions every admin route relies on:

  • Permissions: the role matrix is src/admin/lib/permissions.js (ACTIONS). Routes enforce it with requireRole('admin') / requireCan(action); templates call can(action) only to hide what would be refused anyway.
  • Audit log: src/admin/lib/audit.js records a successful action when the route sets a success flash (req.flash('success', …)). A write that answers without one (a reorder, a partly failed upload, a JSON answer) records itself with req.audit(summary).
  • Stores and forms: the SQL of an entity is src/admin/stores/<entity>.js (createXStore(db), statements prepared once, multi-statement writes in one transaction); the route validates, calls the store and renders. Templates read the camelCase form of toForm(row), never a raw row; a posted form is read by the entity’s fromForm(body).
  • Refused forms come back with 400, the messages and what was typed (admin/lib/crud.js renderForm); flash is for success and for refusals that are not about the input. Handlers may be async — Express 5 forwards a rejection, so there is no try/catch that only calls next(err).
Concern Location
Configuration & validation src/config/ — env.js, limits.js (upload limits, page sizes, the multer factory), siteSettings.js, theme.js, pageTypes.js, homeBlocks.js, libraryGroups.js, transparency.js (the article 30 checklist), siteTemplates/*.json
Database src/db/ — index.js (open + migrate), migrate.js, migrations/*.sql
Public read model src/content/SqliteContentRepository.js — the only content reader for the public site; caches the sitemap list, dropped by content.invalidate()
Pure helpers src/lib/ — Markdown rendering, dates, formatting (format.js), upload URLs, the settings accessor (settings.js), the page renderer, file usages, menu tree, SEO helpers, search text, backup utils, changelog parser, site template
Public routes & views src/routes/*.js, src/views/{layouts,partials,pages}/
Admin src/admin/ — routes/ (HTTP; routes/pages/ is the page editor, one file per tab), stores/ (the SQL, one module per entity), views/, lib/ (permissions, audit, multipart, CRUD helpers, form readers, trash, page editor, setup), middleware/
Services with side effects src/services/ — FileStorage, MailService, BackupScheduler, OffsiteBackupStore, UpdateChecker, backup (the archive; scripts/backup.js is its CLI)
Browser assets public/css/ (tokens, base, components/*.css), public/js/, public/admin-ui/ (admin CSS/JS, Markdown editor)
CLI scripts scripts/ — create-user.js, backup.js, backup-verify.js, restore.js, backfill-image-sizes.js, changelog-section.js, check-coverage.js, deploy-env.sh, server-setup.sh, init-letsencrypt.sh; each .js answers --help
Tests test/ — mirrors src/ loosely; helpers in test/helpers/
  • CSP without 'unsafe-inline'. No inline <script>, no style="…" attribute, no setAttribute('style', …). Add a CSS class or an external file instead. Setting a property through the CSSOM (element.style.x = …, style.setProperty()) is not governed by CSP and is allowed. JSON-LD blocks are data and are allowed. The exact rule: CLAUDE.md → “Content-Security-Policy”.
  • Uploads only through FileStorage: content-sniffed with file-type (never the client MIME or extension), images re-encoded to WebP via sharp with a thumbnail, remove() refuses a file still referenced (the reference list is src/lib/fileUsages.js). src/services and src/lib never require src/admin.
  • Minors’ data (authors, people, photos) is shown in its public form only through src/lib/authors.js — name display and photo visibility are decided there, once.
  • No installation data in code. Names, addresses, colours, menus are settings or rows. test/neutralTemplate.test.js checks a fresh database renders nothing invented.
  • Ukrainian UI, English code. Every user-facing string is Ukrainian; identifiers, comments, commits and these docs are English. Comments in public/ ship to browsers — keep them short and generic.