Commit Graph

15 Commits

Author SHA1 Message Date
e84835a0fb Derive uploaded file extension from mimetype, not originalname (#11)
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>
2026-07-02 21:41:56 -05:00
43db509293 Validate and uniqueness-check username on user update (#13)
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>
2026-07-02 21:38:16 -05:00
81318ae264 Merge branch 'main' into fix/stale-jwt-revalidation 2026-07-03 02:31:59 +00:00
8ad18140d0 Re-validate JWT against the DB in isLoggedIn (#12)
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>
2026-07-02 21:16:43 -05:00
20d7150ab4 Enforce role-based authorization on admin-only routes (#10)
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>
2026-07-02 21:15:12 -05:00
438d252c05 Merge branch 'main' into rte-posts-upgrade 2026-06-30 18:33:16 +00:00
a5a8c1930c RTE Posts upgrade: TipTap editor + sanitization for posts
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>
2026-06-30 13:14:54 -05:00
578bffc51f Hero Phase 1: layout data path + portal renderer
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>
2026-06-28 02:18:09 -05:00
7bb992f58d Wiki Phase 4: full-text search + revision history
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>
2026-06-27 15:49:05 -05:00
7c081ae749 Wiki Phase 3: internal links, backlinks, and tags
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>
2026-06-27 11:25:15 -05:00
4a7dbf0085 Wiki Phase 2: TipTap rich-text editor + inline image uploads
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>
2026-06-27 10:57:27 -05:00
b925114923 Wiki Phase 1: categories, drafts/publish, HTML sanitization
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>
2026-06-27 10:45:21 -05:00
6f55c516c7 Document full-stack setup; fix dev proxy; drop stray temp script
- 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>
2026-06-27 02:14:44 -05:00
6dba6a017c Frontend update 2026-06-26 21:51:27 -05:00
eef79e2403 Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)
- Layered API (router -> controller -> model -> db), serverlinkr pattern
- Public / auth / admin route groups; posts, wiki, settings, users, activity models
- JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS)
- Site LIVE/MAINTENANCE mode with admin preview bypass
- Dual file+console logging (info/warn/error/debug) + HTTP access logs
- Docker Compose (app + MariaDB), schema.sql + seed, .env.example
- Verified end-to-end against MariaDB (27/27 smoke checks)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:58:32 -05:00