The text_block corner handle previously only changed the wrap width, so
the font size never tracked the box — making the editor un-WYSIWYG and
awkward to tune. Now dragging the handle scales every line's font
proportionally with the box, acting as a zoom that preserves the
h1/h2/p size ratios and keeps each line's manually-set baseline.
- Add scaleFontSize(): numeric px sizes (floored at 6px) and simple
rem/em/px strings scale by the box ratio; responsive clamp()/vw
strings are left untouched so the default hero stays fluid.
- Snapshot the box width + lines at drag start so scaling is computed
against the origin (no rounding drift mid-drag).
- Update the canvas hint to note the handle scales text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the botScore middleware's in-memory scoring/ban state to admins.
Previously state lived only in the store Map with no persistence or API — the
only visibility was tailing container logs.
- botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and
ban events (most-recent-first); listState() snapshot of all scored IPs;
unban() to clear a single IP.
- New admin-only endpoints GET /admin/bot-activity and
POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is
activity-logged with the admin username.
- Bot Activity tab: currently-banned table with Unban, plus a recent-events
feed, following the existing admin table patterns.
- Tests for the buffer, listState, and unban (guard lets an unbanned IP back
through). README updated.
Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is
in-memory, matching the store; not persisted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several changes merged today were not reflected in the README. Bring it
back in sync with main:
- Security section: rewrite into Session/authorization, Login hardening,
Uploads/input, and Platform groups — documents DB re-validation of the
JWT per request (#12), role-based authorization (#10), optional TOTP
2FA (#9), login throttling + per-IP backoff, honeypot, bot-scoring/IP
ban, and mimetype-derived upload extensions (#11) + username
uniqueness checks on update (#13).
- Environment variables: add TRUST_PROXY, DEBUG_TRUST_PROXY, TOTP_ISSUER,
TOTP_CHALLENGE_TTL, and UPLOAD_DIR.
- Routes/API tables: add /admin/account and the account/totp endpoints
plus the login/totp second-factor step.
- Tech stack + project structure: note TOTP (speakeasy/qrcode), the
loginProtection/botScore middleware, the totp util, and the Account view.
Docs-only; no code changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
auth.js previously only logged a warning when JWT_SECRET was unset and
then continued to boot. With no secret, jwt.sign/jwt.verify cannot
produce or validate a usable token, so every login silently fails while
the server appears healthy — and booting a production instance without a
configured secret is a safety hazard.
Resolve the secret through resolveJwtSecret():
- production (NODE_ENV=production): throw, so the process refuses to
start without a real secret instead of running unusable.
- dev/other: fall back to a known insecure secret so local login keeps
working, with a loud warning to set JWT_SECRET before deploying.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "may slow the page" prompt is only a client-side nudge — the server
hard-limits uploads at 8 MB. 1 MB was arbitrarily low and nagged on
perfectly normal hero images. Bump to 5 MB (still well under the hard cap)
and pull the threshold + message into a single tooLargeToUpload() helper so
the background, moon, and image upload paths stay in sync.
The Moon stays a dedicated, first-class hero element — only its image
source becomes configurable. Adds optional src/alt props alongside the
existing size/glow.
- HeroElement: the moon renders props.src when present, else falls back to
the default /assets/img/hero-moon.png. Size, glow, and animation are
unchanged. alt is now props.alt (default '', same as before).
- HeroEditor MoonPanel: adds an image upload (reusing the existing shared
api.admin.upload workflow, same as the image/background panels) that sets
props.src, an alt-text field, and a "Use default" reset. Size/glow
controls unchanged.
Fully backwards compatible: existing layouts with only size/glow and no
src render exactly as today via the fallback. No DB, API, or hero-JSON
changes; no migration.
Adds a layered set of protections around the admin login and the app edge.
Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
rejected (coerced to 1) to prevent XFF spoofing that would dodge every
IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
proxy IP without a redeploy. Documents the Omada static-reservation
assumption.
Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
separate per-IP exponential backoff that persists across the rate window.
All failures return one generic message (no user/pass disclosure).
Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
generically and is scored as an unambiguous bot.
Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
signed challenge (stage:'totp', not a session) is required before the
real session is issued.
Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
unconditionally — independent of score/ban state, so a scanner rotating
through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
score with quiet-period decay temp-bans an IP from ALL routes once past a
(deliberately low) threshold, to protect /admin from credential stuffing.
Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
store can't grow unbounded; the interval is unref'd and cleared on
graceful shutdown.
Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multer filename kept path.extname(file.originalname), while the
fileFilter only checked the spoofable client-supplied mimetype. An
attacker could send Content-Type: image/png with originalname x.html,
landing an .html file in /uploads that express.static serves as
text/html — same-origin stored XSS.
- Store the extension from a whitelist keyed by the accepted mimetype
(MIME_EXT), never from originalname. The fileFilter uses the same map
as its single source of truth, so only mimetypes with a safe mapped
extension pass.
- Use crypto.randomBytes for the random filename component.
- Serve /uploads with an explicit X-Content-Type-Options: nosniff
(defense in depth alongside helmet's global setting).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PUT /admin/users/:id validated password and role but not username, even
though updateUser writes req.body.username. A blank/too-short username
could be saved, and a duplicate hit the DB unique constraint and
surfaced as an opaque 500.
- Route: add the same validator used on create,
body('username').optional().isString().trim().isLength({min:3,max:32}).
The trim sanitizer also collapses whitespace-only input so it fails
the min-length check.
- Controller: when the username is changing, pre-check for another user
with that name and return 409 instead of letting the DB throw a 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isLoggedIn trusted id and role straight from the JWT and never
re-checked the database, so a demoted admin kept their old role and a
deleted user kept a working session until the token expired (up to
JWT_EXPIRES_IN). This also undercut the "last admin" guards.
isLoggedIn now loads the user from the DB by the token's id on every
request: a missing user returns 401 (deleted), and req.user carries the
fresh DB row so the current role is always used downstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isLoggedIn only verified a valid JWT, so an authenticated editor could
call any admin endpoint (create/promote/delete users, flip site mode,
change settings). Add a requireRole middleware factory and gate the
sensitive routes with admin-only:
- PUT /site-mode
- GET/PUT /settings
- all /users/* (list/create/update/delete)
Content routes (posts, wiki, categories, tags, uploads, dashboard,
activity) remain available to editors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared rich-text editor's toolbar buttons (especially the link and
image icons) were too small, and the editor body was short and only
scrolled vertically. These styles are shared by every RichTextEditor on
the site, so the fix applies to the wiki editor, the post editor, and any
future ones.
- Toolbar buttons: 30px -> 38px, base font 0.85rem -> 1rem, with roomier
toolbar padding and gap.
- Icon (glyph) buttons (Link, Insert image, wiki-page, Quote, Divider,
Undo, Redo) bumped to 1.25rem so they read clearly.
- Editor body: max-height 460px -> min(640px, 65vh); ProseMirror
min-height 220px -> 320px.
- Body now scrolls both ways: overflow-y:auto -> overflow:auto (wide
images, code blocks, tables can scroll sideways).
- Nudged the internal-link popover offset to match the taller toolbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the wiki's RichTextEditor to the Posts editor and close the
stored-XSS gap on public post bodies.
- RichTextEditor: add `variant` prop — `full` (wiki), `post` (no
internal wiki-page link picker), `minimal` (image-only, for
Screenshots captions). Toolbar sections rendered conditionally.
- PostEditor: replace the body textarea with a lazy-loaded
RichTextEditor in Suspense; variant chosen by category
(minimal for screenshots, post otherwise).
- posts.model: sanitize body via shared cleanBody on create/update,
treat an empty TipTap `<p></p>` as null, and auto-derive the
excerpt from the body (max 280 chars) when left blank.
- sanitizeHtml util: add deriveExcerpt() helper.
- FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with
DOMPurify.sanitize() as defense-in-depth on render.
No schema or dependency changes. Verified end-to-end against the
local stack: 24/24 API assertions and a full UI round-trip across
all four post categories.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses feedback that the editor was too small/cramped (elements overlapping)
and that the generated CSS moon looked bad.
- AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap)
- HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the
column, capped at ~66vh). Because viewport-unit fonts and % positions scale
together, the canvas is now a faithful miniature of the live hero — the default
text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid;
resize math is scale-aware.
- Moon element now renders the actual moon cropped from the hero artwork
(client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS
dot; MoonPanel exposes size + glow.
Verified: at 1440px the canvas is ~785x442 with the panel beside it, default
elements don't overlap, and the moon loads the real image. Build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete.
- element tray adds moon, badge, and image; property panels:
- moon: size / glow / color
- badge: text / background / text color / corner radius
- image: upload (/admin/uploads, >1MB warning) / width% / alt
- corner resize handle on selected elements (image→width%, moon→size,
text_block→box width)
- 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on
- HeroElement: image element shows an "Upload an image" placeholder until a
source is set (a srcless image never ships live)
Verified in-browser: all five element types add + edit; moon resized 64->104px
via the handle; snap grid overlays; a published moon + badge render on the live
portal; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third phase of the hero canvas editor (see HERO_EDITOR.md).
- HeroElement: editor mode — inner content made non-interactive so the wrapper
handles select/drag; selection outline; box width now canvas-relative
(calc(100% - 36px)) so text blocks fit the smaller editor canvas
- HeroEditor: element tray (+ Text / + Buttons), click-to-select, native
Pointer Events drag (position as % of the canvas, clamped), Delete key + panel
delete, z-order (send back / bring forward), and per-type property panels:
- text_block: per-line text / tag / font size (px) / color / bold, add+remove
lines, alignment
- buttons: per-item label / path / variant, add+remove, alignment
empty-canvas click deselects (back to the background panel)
- theme.css: .hero-el-editable outline/hover/selected + grid helper
Verified in-browser: selecting shows the line editor, editing updates the canvas
live, drag repositions, add/delete and z-order work, deselect returns to the
background panel; no console errors. Moon/badge/image + resize + snap are Phase 4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second phase of the hero canvas editor (see HERO_EDITOR.md).
- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
- live canvas preview (16:9) rendering the draft via HeroElement
- background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
grid, overlay opacity slider — all update the canvas in real time
- debounced (800ms) auto-save to hero_layout_draft
- Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
"showing unpublished draft" banner; normal load renders the published layout
No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First phase of the hero canvas editor (see HERO_EDITOR.md).
- settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it
(corrects the design doc — public settings is a whitelist, not getAll();
hero_layout_draft stays admin-only)
- new HeroElement.jsx: renders one layout element by type (text_block,
buttons, moon, badge, image); absolute % positioning with anchor; shared
by the portal now and the editor canvas later
- MoonDot: optional color override for the hero moon element
- Portal.jsx: parse hero_layout (version-checked, try/catch), render elements
sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the
page is byte-for-byte unchanged until staff publish their own
No schema change. Verified: default render matches the old hero; publishing a
hero_layout re-renders the portal; the draft key is not exposed publicly; client
builds; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build contract for the WYSIWYG portal-hero editor on hero-feature, derived
from the design doc and corrected against the codebase:
- public settings is a whitelist (getPublic/PUBLIC_KEYS), so hero_layout must
be added there — the doc's "no backend changes" was wrong
- moon is the reusable MoonDot component; route vs nav live in App.jsx vs
AdminLayout.jsx; admin content is 1000px (canvas scales to fit)
Locked decisions: full v1, buttons as a first-class element type, pre-populate
the current hero on first run, native Pointer Events for drag. Phased plan
with per-phase exit checks. No schema change (JSON in settings).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive): wiki_revisions table (per-save content snapshots).
The FULLTEXT index on wiki_pages(title, body) shipped in Phase 1.
Search:
- MATCH ... AGAINST natural-language search over title + body, ordered by
relevance
- public: GET /public/wiki?q= (published only); admin: GET /admin/wiki?q=
(all statuses)
- public wiki index gains a search box; admin list gains a search field
Revision history:
- every create/update snapshots the page into wiki_revisions
- admin endpoints: list revisions, get one, and restore (restore overwrites
the page, rebuilds links, and appends a new revision — history stays
append-only); logged as wiki.revision.restore
- editor gains a History modal: revision list + word-level diff (jsdiff) of a
chosen revision against the current page, with one-click restore
Verified end-to-end: search matches body and title; two edits produce three
revisions; diff renders added/removed words; restore reverts and records a new
revision. No console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connectivity phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive new tables): wiki_tags, wiki_page_tags, wiki_links.
Internal links & backlinks:
- new wiki.links.js parses a saved body for /wiki/<slug> (and data-wiki-slug)
targets; wiki_links is rebuilt on every save
- article shows a "Linked from" section (published backlinks) and renders
links to non-existent pages as red links (server returns missing_links)
- editor gains an internal-link picker listing existing pages
Tags:
- pages accept a tags[] array; tags upsert on save, page tag-set is replaced,
and orphaned tags are auto-pruned (on save and delete)
- public/admin list filter by ?tag=; /wiki/tags lists tags with published counts
- article shows tag chips; the index has a flat tag-filtered view; editor has a
comma-separated tags field
Verified end-to-end: A->B backlink appears, red link detected, link index
rebuilds on edit, tag filtering + chips + pruning all work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the raw-HTML textarea in the wiki editor with a TipTap (ProseMirror)
WYSIWYG editor.
- new RichTextEditor component: bold/italic/strike, H2/H3, bullet+ordered
lists, blockquote, code block, divider, link, inline image, undo/redo
- generalized POST /admin/uploads (reuses the screenshot multer config) →
{ url }; the editor uploads inline images through it
- editor output still passes through the Phase 1 server-side sanitizer on
save and DOMPurify on render
- lazy-loaded as its own chunk so the public bundle doesn't ship TipTap
(public ~82kB gzip; editor chunk ~106kB gzip loaded only in admin)
- RTE styling added to theme.css (toolbar, active states, prose content)
Verified: uploads serve as images; H2/H3 + lists + link + inline image
round-trip through the WYSIWYG and render sanitized on the public page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive, idempotent via ensureSchema):
- new wiki_categories table; wiki_pages gains category_id, excerpt,
published, published_at, sort_order, and a FULLTEXT index
- migration ALTERs guarded with IF NOT EXISTS for existing databases
- seed reworked into 4 sections with the 8 starter pages assigned
Security:
- new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are
sanitized on every save, and the article renders through DOMPurify
- strips <script>, event handlers (onerror), and javascript: URLs
Backend:
- public: published-only list with ?category filter + /wiki/categories
- admin: extended page CRUD, PATCH publish toggle, category CRUD;
drafts visible to admin, hidden from public
- all writes logged to activity_log
Frontend:
- data-driven public wiki index (sections + real descriptions; removed
hardcoded blurbs/Roman numerals) with ?category filtering
- article: category breadcrumb + sanitized render
- admin: Section/Status columns, draft/publish + section + excerpt in the
editor, and a Manage sections modal
Verified end-to-end against MariaDB 11: migration clean, XSS neutralized,
drafts hidden, client builds, server boots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README rewritten for the completed frontend: full setup/run instructions
(Docker Compose, local dev with hot reload, prod build), pages/routes,
API endpoints, and a consolidated environment-variable reference
- Vite dev proxy now targets 127.0.0.1 (avoids the Windows IPv6-localhost
pitfall where the SPA could not reach the IPv4-bound API)
- Remove server/_setup.ps1 (a scratch script accidentally committed in the
previous frontend commit) and gitignore _*.ps1 scratch files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>