Author SHA1 Message Date
remco c12319db0b Add renovate.json
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (pull_request) Failing after 21s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-10 08:00:07 +00:00
openhands 48291ab641 fix: correct the ACL revoke migration and update the login redirect e2e
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m56s
CI / tests-unit (push) Successful in 2m8s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m51s
The deploy gate found two defects in the previous commits, both mine.

0034_acl_midrank_revoke.sql never applied: it joined `acl_roles` on
`ar.model_type`, a column that table does not have (only
`acl_model_permissions` does). It now joins on the id and keeps the
`model_type` check where it belongs.

That hid a second, worse bug. The rank was extracted with
`SUBSTRING(slug, 7)`, but MySQL's SUBSTRING is 1-based and the digits start at
position 6, right after `rank_`. rank_10 therefore parsed as 0 and rank_7 as an
empty string, so every rank >= 7 would have lost exactly the grants the
migration exists to preserve — the ACL repair would have made things worse, not
better. Now reads from position 6.

Verified against a real MariaDB with a fixture covering rank_1, rank_6, rank_7,
rank_9, rank_10 and a non-rank slug: only the sub-7 roles lose their non-view
admin.* grants, the multi-digit and higher ranks keep everything, and the
non-rank slug is untouched. The mail index was checked the same way — it
applies idempotently and EXPLAIN confirms `users_mail_index` with rows: 1.

news.spec.ts expected to land on /me after signing in. That expectation predates
the `?from=` honouring added in 39332149, which lands a bounced admin back where
they were heading. The same step navigates to /admin/articles/new explicitly a
few lines later, so nothing depended on it; the assertion now covers the redirect
target instead.
2026-10-09 18:21:09 +02:00
openhands 6b34293cd3 fix: apply the 44px button target only to coarse pointers
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 27s
CI / tests-integration (push) Successful in 1m35s
CI / tests-unit (push) Successful in 1m38s
CI / tests-ui (push) Successful in 2m20s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m55s
The UI screenshot suite caught a regression from the previous commit: the admin
article form grew 3px and its baseline no longer matched.

The cause was the blanket `.btn` min-height bump from 40px to 44px. That was
the wrong way round — 40px already clears WCAG 2.2 AA, which asks for 24px, and
44px is a touch-target guideline. Growing every button for mouse users only made
each admin dialog and table 4px taller for no benefit.

The 44px floor now sits behind `@media (pointer: coarse)`, so finger input gets
the comfortable target and desktop keeps its density. A hybrid laptop still
uses the desktop metrics for its trackpad, which is the behaviour the previous
attempt got wrong in both directions.
2026-10-09 18:04:21 +02:00
openhands 5cb42c8dfb fix: stop the commandocentrum audit call leaking an unhandled rejection
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 27s
CI / tests-unit (push) Successful in 1m35s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Failing after 2m25s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The full suite passed but exited non-zero, which fails CI: three unhandled
rejections came out of commandocentrum's fire-and-forget audit call.

The cause is a real defect, not a test artefact. `auditAction` wrapped
`logAudit(...)` in a try/catch to honour "auditing must never fail the command
it describes", but logAudit is async, so the catch can never see its rejection.
A failing audit insert therefore surfaced as an unhandled rejection instead of
being swallowed — in production that is a request taking down over a logging
failure. The catch is now on the promise itself.

The test now mocks the audit service explicitly instead of leaning on the fake
db lacking `insert`, and asserts both that an entry is logged and that a
rejecting audit still lets the command succeed.
2026-10-09 17:57:28 +02:00
openhands 759ae91745 perf: cache search and news archive, drop motion/react from public pages
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 26s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Failing after 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four remaining LOW items.

Search and news archive caching
- A leading-wildcard LIKE cannot use an index, so every /search section cost a
  COUNT(*) scan plus an ordered page fetch, and /news did the same for its
  archive. Both now cache: search per section for 30s, the archive for 60s
  under the existing news revision so publishing an article drops it at once.
- Sections are cached independently, so one slow query cannot hold up the rest
  and a failure is not cached as a result.
- The cached value is passed through cacheSafe() so the Redis path and the
  in-process path return the same types; without it a cache hit would hand the
  events grid a string where a miss hands it a Date, and it calls toISOString()
  on that field. Dates are revived on the way out so the public signatures of
  loadNewsArchive and loadPublicSearch are unchanged.
- Archive entries are keyed on the REQUESTED page rather than the clamped one,
  so two requests that clamp onto the same page cannot alias each other.

motion/react out of the public bundle
- Converted the six public-facing users: the radio player, the typewriter text
  (a motion.span with no animation props at all), the photo lightbox, the
  animated counter, the footer CMS-info popup and the scroll reveal. That was
  the actual entry points — the counter and the popup reach the public home page
  and footer through static imports, so removing only the three originally named
  would have left the library in the bundle anyway.
- Each animation moved to a CSS class, and the two that animate on exit now hold
  the element for the length of the fade, which is what AnimatePresence used to
  do.
- motion/react now only ships with /admin and the two already-lazy nav panels.
- Two safety fixes came out of this: the scroll reveal starts at opacity 0, so
  it is forced visible under prefers-reduced-motion and via a <noscript> rule in
  the root layout; and it now emits the .motion-reveal class, which the theme
  panel's "Scroll Reveal" toggle selects and which previously matched nothing.
- The CMS-info backdrop became a real button in a pointer-transparent layer
  instead of a handler on a static element, so click-outside-to-dismiss is
  reachable by keyboard.

Fewer duplicate router refreshes
- Next.js re-renders the current route as part of a server action's own response
  when that action revalidates, and applies it with a seeded navigation; the
  router only skips its own update when the action did NOT revalidate. So the
  refresh after such an action fetched the same tree twice.
- useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in
  per call rather than derived from an action name, since a rename would
  silently change behaviour. Applied to the two user-facing call sites whose
  actions were verified to revalidate their own route.

Touch targets
- .btn was the one shared control at 40px; it and the lightbox and CMS-info
  close buttons are now 44px, as is the password toggle (the auth input already
  reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which
  only asks for 24px; enlarging those inside inputs and overlays was left alone
  because it risks visual breakage that cannot be checked from here.
2026-10-09 17:35:38 +02:00
openhands 179484642f feat: per-account login lockout, mail index, resend captcha, i18n scoping
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m45s
CI / tests-ui (push) Successful in 2m29s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four HIGH/MEDIUM items left open after the previous pass.

Login lockout
- The only login limits were keyed on the client IP, so a distributed attempt
  could grind on one account indefinitely. Added a per-account lockout with a
  budget of 8 failures per 15 minutes.
- The bucket is keyed on the RESOLVED account id, not on the submitted string:
  users may sign in with either username or e-mail and neither the lookup nor
  the input normaliser folds case, so an input-keyed bucket would hand out a
  fresh budget per spelling of the same account.
- precheckLogin and NextAuth's authorize share the bucket, so the pre-check
  cannot be used to buy extra attempts and a client that skips it entirely is
  still bounded. Both check the lockout BEFORE verifying the password: the
  success path clears the counter, which would otherwise walk a locked account
  straight back in on the right password.
- A successful login clears the failures, which needs two new primitives in
  rate-limit.ts: peekRateLimit (read-only, does not consume a unit) and
  clearRateLimit.
- Fixed a latent inconsistency while doing so: the in-process bucket capped its
  counter at the limit while Redis' INCR kept climbing, so the two backends
  disagreed about how far over the limit a key was. Both now track the true
  count.

Mail lookup index
- Added an index on users.mail (0035). Password reset, e-mail verification and
  the resend cooldown all resolve a single account from a submitted address and
  were full table scans of `users`. Deliberately non-unique: legacy rows can
  hold the same address more than once, so a unique index would fail to apply.

Resend captcha
- /verify's resend form triggers real outbound mail and was reachable with only
  a cooldown. It now runs the configured captcha before the account lookup and
  before any send.

Client message payload
- The root layout serialised the whole catalogue into every page. pages.admin
  and admin are ~177 KB of the ~235 KB and are unreachable from the public route
  group, so that layout now installs its own provider with the staff namespaces
  removed. Nested providers replace rather than merge, which is why this has to
  live in the segment layout. /admin, /mod, /client and /admin-next keep the
  full set; a guard test fails if a public page ever references a staff
  namespace.
2026-10-09 17:12:50 +02:00
openhands 6cc45d7413 feat: harden atoms-nexst against review findings (37 items)
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Second review pass covering security, performance, admin tooling and the
public/room flows. All HIGH and MEDIUM findings from the audit are resolved;
nothing in this commit changes the visible feature set.

Authentication & session security
- CSP is now set on the request headers in the proxy, which is what Next.js
  uses to derive the render nonce, so the nonce is effective.
- 2FA: an already-enabled user cannot re-enroll, the setup endpoint is
  rate-limited per account, and confirmed codes are persisted so the second
  secret no longer silently never applies.
- Password reset revokes the ticket, authTicket and all personal access
  tokens, and bumps the token version so existing sessions die. The same
  revocation is now wired into the staff-side password reset.
- /reset and /verify return a stable error code instead of raw text; the
  mail lookups are ordered by id so duplicates cannot vary between runs.
- Resending the verification mail gets a per-address cooldown on top of the
  per-user limit.
- Issue API tokens with the narrower radio/ticket ability set instead of "*".

Authorization & input handling
- Mid-rank staff can no longer keep dynamically granted non-view admin.*
  permissions: existing grants are revoked by migration and the grant lookup
  is restricted to "%.view". Rank guards use the dynamic super-admin check.
- Alerting a user is permission-checked and audited like the other tools.
- Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user
  actions route, bulk user actions) are capped and rank-guarded, and bulk
  ids are bounded.
- updateRoom / updateRoomItem write through a field allowlist, and items
  may only be edited through their own room.
- Classnames reaching the filesystem are validated before use so a crafted
  value cannot escape the asset directories.
- The word filter now also covers offline mails, guild forum threads and
  replies, and user mottos.
- Media uploads are validated by magic bytes, /api/media requires the page
  edit permission, APP_URL must be configured once mail is enabled, and the
  diagnostics error route checks the fetch site header.

Admin tooling
- Secret settings render masked and cannot be overwritten with a blank or
  an arbitrary raw key; radio credentials are new password inputs.
- Commandocentrum balance changes are audited.
- Admin list pagination reads the caller's per-page instead of the max, and
  the log exporter caps offset and search length.

Performance
- Catalog translations are cached per module, with a cheap revision hash;
  the public online count uses a stale window instead of hammering the DB.
- The cache warmup now primes the payload the home route actually reads.
- TopHeader batches its queries into one round trip, and LCP avatars load
  eagerly.
- motion/react and sonner are no longer part of the root layout; the nav
  dropdown and mobile nav panels are lazy client chunks. Anonymous visitors
  again get the navigation chrome, and public pages get an edge cacheable
  response.

Accessibility
- Nested <main> elements in phase pages became <section>; the page entrance
  and route progress animations are pure CSS that respect reduced motion.
2026-10-09 16:19:48 +02:00
openhands 3933214953 feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s
- add countArticles() (published-only, mirrors news-list) and warm total_articles
- localize homepage metadata; bind articleCount to both stats; unique photo alts
- drop duplicate news date and the mascot preload priorities
- extract shared AuthPageFrame/AuthUsersCards used by /login and /register
- login: localized noindex metadata, session redirect via safeRedirectPath,
  ?from passthrough from proxy, unified auth roster cache keys, registered notice
- register: localized metadata, session redirect to /me, unified cache keys
- add resend-verification flow on /verify with rate-limited non-enumerable action
- add safeRedirectPath() with unit tests
- register form: live requirements checklist + password mismatch guard
- login form: unverified state with resend-link CTA
- honour prefers-reduced-motion in TypewriterText
- add 6 translations across all 25 locales
2026-10-08 18:49:27 +02:00
openhands 8561c3f85e fix(ci): accept any runtime the engines range supports
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 42s
CI / tests-unit (push) Successful in 1m42s
CI / tests-integration (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m28s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s
The active-runtime assertion required process.versions.node to equal
.nvmrc exactly, so any Node.js patch release broke `toolchain:check` and
the act CI run even though package.json engines (>=26.10.0 <27) supports
the newer runtime.

Keep .nvmrc and the Docker base image exactly pinned for reproducibility
(both still asserted), but validate the running runtime against the
engines range instead. Verified: toolchain:check, lint, typecheck,
i18n:check, hk:matrix:check and all 3391 tests pass.
2026-10-08 17:28:56 +02:00
openhands d2350a6427 fix(ci): install bash in the Docker build stage
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Failing after 17s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The build script now runs every heavy command through
scripts/with-memory-cap.sh, which is bash (arrays, BASH_REMATCH). Alpine's
node image ships busybox ash, not bash, so the builder stage failed with
`sh: bash: not found` (exit 127): ci-deploy.sh could not build the image.

Verified: full docker build passes and compiles all 279 routes.
2026-10-08 17:19:06 +02:00
openhands 0845f80768 fix(ops): run heavy commands under a hard memory cap to stop host OOM kills
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 35s
CI / tests-integration (push) Successful in 2m5s
CI / tests-unit (push) Successful in 2m30s
CI / tests-ui (push) Successful in 3m3s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 44s
The host runs with vm.overcommit_memory=0 and no swap, so a process that
grows past free memory makes the kernel OOM-kill across the whole machine
-- the Turbopack build (commit 3d828a61) could take out the database,
nginx or the live release.

Add scripts/with-memory-cap.sh: it moves a command into its own systemd
scope with MemoryMax, so only that cgroup gets OOM-killed (verified: a
Turbopack build died at its 6GB cap, host untouched). Build/analyze/dev/
test*/typecheck now run under explicit caps; ulimit -v is only an explicit
opt-in because it bounds virtual address space per process and 10g/20g both
break V8-based builds. Docker and GitLab builds run in their own isolated
containers with a read-only cgroupfs and opt out explicitly (webpack +
--max-old-space-size stay their bound).

Measured: webpack build peaks ~6.5GB RSS, so 10GB leaves headroom within
the 23.5GB host.
2026-10-07 20:17:00 +02:00
openhands 3265c149da style(landing): add micro-interactions and polish public pages
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m48s
CI / tests-integration (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m7s
2026-10-06 22:59:03 +02:00
openhands 57710a7fe3 style(landing): polish the public index, login and register screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Failing after 26s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- add theme-aware helpers: btn-brand, btn-glass-dark, auth-input, auth-label,
  auth-alert, explore-pill, avatar-tile, aurora blobs and a hero scroll cue
- reorder backdrop-filter declarations so the glass blur survives the
  production CSS optimizer in modern Chromium
- hero: aurora glow, frosted recent-users chip, premium CTA buttons and cue
- explore nav: icon pills with hover arrows; features: gradient icon tiles
- stats: single glass panel with column dividers; join CTA: aurora + ring
- auth forms: visible labels, icon inputs, eye/password toggle, gradient
  submit and pill footer links
- auth pages: gradient card frame, aurora accents on the intro panel and
  hover-lift avatar tiles; unified pill-shaped top bar
- drop the hard-coded register banner image in favor of the framed card
2026-10-06 22:00:02 +02:00
openhands 5b2eb91c5c fix(ops): stop a compose replica from blocking the blue/green release
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m57s
CI / tests-unit (push) Successful in 2m3s
CI / tests-ui (push) Successful in 2m49s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m48s
The deploy failed after the build, the migrations and the browser gate:
"Port 3002 is already in use". The holder was `epicnext-cms`, a compose
replica of release 6bffc537 that the daily scripts/docker-update.sh cron
had recreated at 03:30 with restart=unless-stopped. nginx serves the green
slot on 3003, so that replica was squatting the blue slot the next
candidate needed, and live traffic never noticed.

It got there because the updater's CI-ownership guard only tested
epicnext-cms-app. After a cutover to the green slot that container is
stopped, renamed and deleted, so the guard stopped firing while the host
stayed CI-managed.

- scripts/docker-update.sh: refuse a compose deployment on a CI host by
  checking both slot containers and the nginx upstream, which is the only
  thing that still marks the host as blue/green while a slot is idle.
- scripts/ci-deploy.sh: retire a compose replica of this checkout from
  the candidate port before starting the candidate, so a stray replica
  can never block a release again. Never a slot container, never the port
  nginx serves; anything else still fails loudly in assert_port_free.
- Tests cover both directions: a squatting replica is removed and the
  release lands, a replica on the live port is left alone.
2026-10-05 21:13:49 +02:00
openhands 11ad6d4376 fix(ci): measure route bundles from webpack manifests
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m41s
CI / tests-unit (push) Successful in 1m44s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m25s
The performance report measured nothing. It only read `entryJSFiles` from
each route's client-reference manifest, a field Turbopack emits and webpack
does not. When the build moved to webpack (3d828a61) every route fell
through to the "unavailable" branch, and because the report is informational
and exits 0 on an unavailable metric, nothing failed and the budgets quietly
stopped being enforced.

Derive the envelope from clientModules[*].chunks when entryJSFiles is
absent, which is the same source Next's own static-routes-info uses for
webpack builds. Webpack interleaves numeric chunk ids with file names in
those arrays, so ids are skipped by shape while a malformed chunk path still
throws — otherwise a broken manifest would quietly under-report a route.
entryJSFiles still wins when present, since it is per-segment and therefore
the tighter envelope, and the per-chunk origin label is shared rather than
the absolute node_modules path webpack records, which would otherwise bloat
report.json.

Six tests cover the webpack layout: id filtering, deduplication of a chunk
reached by several client modules, the origin label, the malformed-path
rejection, the no-chunks-at-all case, and entryJSFiles taking precedence.

Re-measured on the current build, all six routes are inside their budgets
again. Note /admin/studio/furni now sits at ~98% of its gzip limit, so one
more dependency on that route will trip it; docs/performance-budgets.md
records the webpack baseline numbers and how to recalibrate.

Verified: 3385 tests, typecheck and biome clean, and the report now emits
measured rows instead of six unavailable ones.
2026-10-05 20:49:53 +02:00
openhands 6c3d81920e fix(ops): supervise the job worker and stop the health probe from lying
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 1m49s
CI / tests-ui (push) Successful in 2m33s
CI / tests-integration (push) Successful in 1m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m26s
Four production defects, all found by auditing the running host rather than
the code. Each one had a signature that looked like a network or permissions
problem and was actually a configuration or ordering bug.

jobs-worker never ran

`import "./load-env"` sat on line 3 of scripts/jobs-worker.ts, but ESM
evaluates a module's imports in source order and the first import reaches
`@/env`, which validates process.env at import time. The ZodError on
DATABASE_URL therefore fired before load-env ever executed, so the worker
could only start from a shell that had already exported the configuration.
Nothing supervised it either, so scheduled articles, catalog export, JAR and
database backups, disk alerts and the ops health probe have all been dead;
`cms:jobs-worker:heartbeat` did not exist. Moved the import to the top and
added deployment/systemd/cms-jobs-worker.service with Restart=always.

The JAR backup additionally pointed at './emulator/Arcturus.jar', which does
not exist and would go stale on the next emulator upgrade. resolveEmulatorJar
now accepts a file, a directory or a wildcard and picks the newest JAR, the
same way emulator.service picks its build, and reports an unresolvable path
once instead of logging an opaque copyFile ENOENT every night.

/api/health answered 200 with the database down

The route documented this as intentional, and ci-deploy.sh worked around it
by grepping the body for '"database":true'. The container healthcheck did not,
so Docker reported containers healthy while every page 500'd. The status is
now load-bearing: 503 when the database is unreachable, 200 otherwise. Redis
and the emulator deliberately do not fail the container — both have in-process
fallbacks, so failing them would trade a slow site for an outage.

The runtime had no V8 heap cap

NODE_OPTIONS existed only in the builder stage. With no cap, V8 sized its
heap from host memory (23.5 GB) while the container was limited to 4 GB, so
the kernel OOM-killed the process mid-request — the same failure mode as the
14 host-wide `next-build` kills. docker-start.mjs now reads the cgroup limit
(v2 with a v1 fallback) and sets 70% of it, respecting an explicit override.

Storage ownership was only repaired for one path

ci-deploy.sh chowned storage/imaging and nothing else, so
storage/catalog-git/hotel-status.json kept coming back root:root and
/api/admin/catalog/status kept throwing EACCES. All eight writable storage
paths are repaired now. The silent-failure mode is the reason this mattered:
these writes sit inside try/catch, so a wrong owner looks like a slow page
rather than an error.

nginx: robots.txt was a guaranteed 404, and TLS never resumed

`index index.html` without a `root` left every try_files resolving against
/etc/nginx/html, which sits behind a 0750 directory — the worker got EACCES
on each stat and nginx logs a failed stat at crit, which is where 149 crit
lines per scan came from. robots.txt answered from that same broken location,
so crawlers were pointed at a file they could never read while sitemap.xml
kept advertising it. Added `root`, proxied robots.txt to the CMS, added
ssl_session_cache (there was no session resumption at all), and set
Restart=on-failure in a systemd override, since the packaged unit ships
Restart=no and nginx is the only thing serving the site.

Verified against the running host: 3379 tests, typecheck and biome clean,
nginx -t passes, health returns 200 with every check green, and the worker has
run for hours at NRestarts=0 with a heartbeat refreshing each minute.
2026-10-05 20:25:22 +02:00
openhands 108c6ce03d fix(ci): make the lint gate fail for real and stop byparr leaking disk
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 2m3s
CI / tests-unit (push) Successful in 2m18s
CI / tests-ui (push) Successful in 3m6s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 3m14s
The CI lint step was `biome check . || true`, so it could never fail: 14 real
violations were passing unnoticed. Drop the `|| true` and fix what it found.

Lint fixes, none of which change behaviour:
- give list items their natural identity instead of the array index
  (key={c} / key={char}, key={`skeleton-${i}`})
- document the two useEffect dependency lists that must keep their
  function-declaration handlers, with the reasoning that dropping them broke
  the tree and save-on-Ctrl+S once already (704e3363)
- scope the remaining noArrayIndexKey / useExhaustiveDependencies exemptions to
  the three files that need them, in biome.json instead of scattered comments

Storage, on a host that had grown to 81% disk:
- byparr starts a Firefox per request and never removes the profile it leaves in
  the container's writable layer. With no volume mounted, nothing else reclaimed
  it: 716 profiles / 6.8 GB in two days, ~1.7 GB/day. docker-prune.sh now removes
  orphaned profiles, identifying live ones by the open fd in /proc/<pid>/fd rather
  than by age, because browsers stay warm for ~27 hours here — longer than the
  leak window, so no age threshold can be both safe and useful.
- bound the build cache properly: buildx treats --max-used-space and --filter as
  mutually exclusive, so passing both silently dropped the 4 GB cap and the cache
  reached 49 GB.
- escalate to the emergency prune when / drops below 8 GB free, so the bound holds
  even if the schedule stops.
- clear multi-GB tmp_pack files left behind by a gc that was OOM-killed
  mid-repack; git only removes those on the next successful gc.
- make setup-cron.sh append instead of replacing the crontab (`crontab -`
  overwrites the whole file, which had been dropping the other scheduled jobs),
  and run the prune daily rather than weekly to match the leak rate.

Volumes are still never pruned: mariadb-turbo-data is a database.
2026-10-05 17:24:12 +02:00
openhands 6bffc53779 refactor(auth): merge the duplicate login form and localize the auth screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical
components. Delete the former and give `LoginForm` a `variant` prop:

- `variant="page"`   sr-only labels plus the register/forgot footer (/login)
- `variant="compact"` visible labels, no footer (homepage sidebar)

Field ids now come from `useId()`, so the two usages can never collide, and the
hardcoded "Show"/"Hide"/"Loading" strings are translated.

Localization of the login and register screens:

- `home-login-form.tsx` was entirely hardcoded English.
- `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong".
- `register.ts` returned only English strings. It now returns a
  locale-independent `code` next to the message, and the form renders
  `t(code)` with the English string as a fallback.
- Backfilled the new keys across all 25 locales, plus the login/register
  strings that were still English in most of them. `ar`, `fi` and `ja` had
  their entire login/register namespace in English and are now filled in.
  Locale parity stays at 0 missing keys, as `i18n:check` requires.

Copy that did not match the enforced rules: the UI advertised "min 8 chars"
(EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an
uppercase, a lowercase, a digit and a special character. Corrected in every
locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to
match registration.

Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on
any field. All three are now present, and error banners are announced with
`role="alert"`.

Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode`
resolves to a non-empty message in all 25 locales; verified it fails when a key
is removed. The existing register tests now also assert the error `code`.
2026-10-04 18:50:23 +02:00
openhands 3d828a61ab fix(build): build with webpack because the Turbopack build is OOM-killed
`next build` on Turbopack never completes on this app. The compiler is a
single native process whose RSS grows monotonically with no plateau:

    0.9G -> 1.6G -> 2.8G -> 5.0G -> 5.5G -> 6.2G -> killed

It still dies with 4GB of swap attached, at 12GB RSS. The build workers are
only 0.17GB each, so `experimental.cpus` is not the lever either.

A `--max-old-space-size` cap cannot help: measured with a 2GB cap, RSS still
reached 8GB, because the memory is native Turbopack (Rust) memory rather than
the V8 heap. The cap added in 1c9ddcd4 was therefore inert and only created
false confidence, so it is dropped from the build script.

Webpack builds the same 329 routes in ~95s with a ~6GB peak.

Ruled out by measurement: the 25 bundled locale files (stubbing 24 of them
from 7.1MB down to 276KB still peaked at 11GB), worker count, and the
flatten/unflatten message pipeline (600 iterations cost 6.4s and settle at
39MB of heap).

Verified: `pnpm run build` exits 0, TypeScript passes, 279/279 static pages are
generated, and the standalone output boots and serves /, /login and /register.
2026-10-04 18:50:11 +02:00
openhands 7f07c111ac perf(studio): load motion's minimal entry instead of the full component library
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m48s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m34s
/admin/studio/furni sat at 94.9% of its initial-JS budget (427436 of
450560 gzip bytes), so the next feature would have broken the build. Of the
98228 gzip bytes unique to that route, a large part is framer-motion.

This file uses motion twice, for one thing: a 150ms opacity fade on the result
pane when viewMode changes. Importing `motion/react` to get it pulls in
framer-motion's complete component library — 73 internal modules — plus its
render components, drag/gesture and projection code, none of which is
rendered here.

`motion/react-m` ships only the element factories: 2 internal modules, and the
same initial/animate/transition props, so the fade is unchanged. It exports the
elements flat rather than under a `motion.` namespace, so the import becomes
`div as Mdiv` and the two JSX tags are renamed to match.

I could not measure the resulting bundle here: the local build is OOM-killed
(exit 137) with the running containers on the host, so the actual saving is
unverified. The CI build reports it in build-reports, and the number in this
commit message should be read as a hypothesis, not a measurement.

Verified: typecheck clean, lint clean, and the 10 studio UI tests pass —
including the pane and navigation specs that exercise the view switch.
2026-10-03 19:21:02 +02:00
openhands 8218039c64 test(live): stop the live suites inheriting the production database
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-unit (push) Successful in 1m57s
CI / tests-integration (push) Successful in 2m1s
CI / tests-ui (push) Successful in 2m51s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m42s
Seven suites read .env with a bare `process.env[key] = value`, which
overwrites whatever the shell already set. That made the DATABASE_URL from
the production .env authoritative, so a single environment variable was
enough to aim them at the live hotel database:

  RUN_CATALOG_AUDIT_LIVE=1 pnpm vitest run src/lib/services/catalog-audit-repair-live.test.ts

Three of those suites then repair the catalog in place: catalog-audit-repair-live
and catalog-repair-direct-live rewrite catalog_items and delete duplicate
classnames, and clone-bulk-import-live bulk-imports every cloneable item. None
of that is undoable, and nothing in their output said the target was
production rather than a sandbox.

Added src/test/live-env.ts with one shared loader, and pointed all seven suites
at it:

- Values already in the real environment win, so an explicit DATABASE_URL on
  the command line is always respected.
- DATABASE_URL defaults to the sandbox on port 3307 rather than inheriting the
  production one from .env.
- Anything that is not loopback is treated as production and redirected.
- Reaching production requires ALLOW_PRODUCTION_LIVE_DB=1 and logs a warning
  saying the suite repairs the catalog.

Tests in src/test/live-env.test.ts run the loader against a temporary .env so
the real project file is never read, and cover the redirect, the shell
override, non-loopback detection, the opt-in and quote stripping. A second
block asserts each of the seven suites no longer contains an inline
`process.env[...] =` assignment. Verified four of them fail against the old
loader.

This does not enable the suites; they stay gated behind their RUN_* flags.
It only removes the possibility of them silently hitting production.

Unit suite: 3330 passed, 12 skipped. Typecheck and lint clean.
2026-10-03 19:03:28 +02:00
openhands f705c67fc7 revert(docker-compose): keep the cms services the contract tests require
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m44s
CI / tests-ui (push) Successful in 2m34s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s
The previous commit removed the `cms` and `cms-green` services from
docker-compose.yml. That was overreach and it broke two tests:

- src/lib/docker-build-contract.test.ts asserts the compose build passes
  NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}, so a compose-built image
  carries its release id.
- scripts/proxy-config.test.mjs resolves `docker compose config` and asserts
  the `cms` service's host networking, volumes, healthcheck and image tag.

Both encode that docker-compose.yml is a maintained deployment surface, not a
leftover. Removing it was not my call to make while fixing a deploy.

Restored verbatim. The stray container that actually blocked port 3002 is
already gone, and nothing recreates it: there is no systemd unit or pm2
ecosystem that runs `docker compose up`, and `restart: unless-stopped` only
applies to a container that still exists. So the blocker is resolved by the
container removal alone, and compose stays intact for manual and reviewed use.
2026-10-03 18:51:06 +02:00
openhands c8b3054527 fix(deploy): free port 3002 and stop compose from competing for the slots
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m46s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m39s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The deploy could not start its candidate because port 3002 was held by
`epicnext-cms`, a `docker compose up` replica built from the `local` image and
serving no traffic. Everything else in the pipeline was healthy: the image
built, the news browser gate passed and migrations were current.

The container was unusable for this pipeline for two reasons. It ran a
different image than any release, and its name did not match the slot the
deploy script manages — docker-compose.yml pinned `container_name: epicnext-cms`
while ci-deploy.sh expects `epicnext-cms-app` for slot A. Slot B happened to
agree (`epicnext-cms-green`), which is why 3003 deployed fine and 3002 never
could. deploy.sh already documents that compose "never managed the release
that actually ran", so the service was stale by its own account.

Removed the stray container and dropped the `cms` and `cms-green` services (plus
the now-unused x-cms anchor) from docker-compose.yml, so a reboot cannot
resurrect a replica that permanently occupies a blue/green slot. byparr is
untouched.

Also fixed the diagnostic from the previous commit, which blamed every running
container. `docker ps --filter publish=` returns nothing for --net=host
containers, so the fallback listed all of them and buried the real holder
among seven innocent ones. It now resolves the listening PID from `ss` back to
its container through /proc/<pid>/cgroup and names only that one, with the
exact `docker rm -f` command to run.

Verified: port 3002 free, live release on 3003 still serving
(status ok, database and redis true), deploy simulation 26 passed, typecheck.
2026-10-03 18:45:51 +02:00
openhands 8ec3df541e fix(deploy): name the container blocking a port and silence phantom cleanup
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m33s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m30s
Two follow-ups from the blocked deploy.

The rollback path called `docker logs` and `docker rm -f` on the candidate
unconditionally. When the port check refuses to start it, the container was
never created, so both printed "No such container: epicnext-cms-app" — noise
that looked like a second, unrelated failure and buried the real message.
Both calls are now guarded by `docker inspect`.

assert_port_free() now reports which container holds the port and flags it when
it is not a blue/green slot this script manages. The previous output listed
every container and said only "port already in use", which is a dead end: on
this host the holder is `epicnext-cms` (a `docker compose up` replica on port
3002), while the deploy manages slot A as `epicnext-cms-app`. The names differ
because docker-compose.yml pins `container_name: epicnext-cms` for the `cms`
service; slot B happens to match, which is why 3003 deploys fine and 3002 never
can. The message now names the squatter, explains that live traffic is
unaffected, and gives the next action.

Deploy simulation: 26 passed.
2026-10-03 18:37:12 +02:00
openhands 8ee144745a fix(deploy): stub ss in the deploy harness and cover the port-conflict path
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m47s
CI / tests-unit (push) Successful in 1m54s
CI / tests-ui (push) Successful in 2m40s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m41s
The port-conflict guard added in the previous commit made the six existing
blue/green deployment simulation tests fail. assert_port_free() shells out to
ss, and the simulation harness stubs git, curl, docker, nginx, pnpm and node —
but not ss. Because the runner is self-hosted and the containers use
--net=host, the simulation saw the production CMS containers holding 3002 and
3003 and refused to start its own candidate.

The harness now stubs ss. It reports no listener for every scenario except
'port-taken', which reserves whichever port the script asks about, so the
simulation stays independent of the host it runs on.

Also switched the ss probe from `command -v ss` to `type ss`. The stub is a
shell function delivered through BASH_ENV; `command -v` happens to find it,
but `type` is the reliable test for "is this resolvable", and the two differ
across shells.

Added a regression test for the guard itself: with the candidate port already
occupied, the deploy must fail, must not have run `docker run`, and must leave
the nginx upstream untouched on the old port — no half-finished cutover.
Verified it fails when the assert_port_free call is removed.

Deploy simulation: 26 passed. Full unit suite: 3316 passed, 12 skipped.
2026-10-03 18:30:00 +02:00
openhands 64ad9baf39 fix(deploy): trust the nginx upstream when picking the live slot
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m48s
CI / tests-unit (push) Failing after 1m54s
CI / tests-ui (push) Successful in 2m46s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The deploy failed with "Expected release never became healthy" after 30
attempts. Root cause: read_active_port() counted the slots answering
/api/health and only consulted the nginx upstream when the count was not
exactly one. On this host both slots were healthy, so it fell back to the
upstream file, but a leftover epicnext-cms:local replica was holding slot A
(3002). The candidate was assigned that occupied port, docker run died with
EADDRINUSE, and the health probe then answered from the pre-existing
container on that port. That container reports release "unknown" because it
was built without NEXT_DEPLOYMENT_ID, so the release comparison could never
match and the deploy timed out blaming a release that was never serving.

read_active_port() now orders its sources by how well they describe reality:

1. The nginx upstream file. It is the only source that says where public
   traffic actually enters; everything below it is a consequence.
2. A healthy slot matching that pointer.
3. The other slot when the pointer names a dead port.
4. The pointer itself when nothing answers, so rollback still has a target.
5. Slot A when no upstream file exists at all.

answers_health() was added as a retry-free sibling of healthy(); port
detection should not spend 90 seconds per slot on a process that is either
running now or never will.

start_candidate() now calls assert_port_free() before docker run, so an
occupied port fails immediately and names the listener and the containers
involved, instead of surfacing later as a misleading health-check timeout.

Added scripts/ci-deploy-ports.test.sh, which extracts the two functions from
the real script rather than copying them, and covers the regression: with
both slots healthy and nginx serving slot B, the result must not be slot A.
Verified the test fails against the old logic and passes against the new.
Wired into the check job so this is caught before an image is built.
2026-10-03 18:22:40 +02:00
openhands 704e33638f fix: restore six useEffect dependencies removed while silencing lint
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m38s
CI / tests-integration (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m58s
The previous commit dropped biome-ignore comments to clear
useExhaustiveDependencies diagnostics and, in doing so, also deleted the
dependencies themselves. Six components were left with effects that no longer
react to the state they read. Every one of these is a real behaviour
regression, not a lint preference:

- health-check-client: checkEmulator is a function declaration, so it gets a
  fresh identity each render. As an effect dependency that re-fires the effect
  after every setState, polling /api/admin/devops/health in a loop. Wrapped in
  useCallback so the identity is stable.
- article-recovery: reload restarts the autosave timer for the "Retry recovery"
  button. Without it in the deps that button is a no-op. The counter had been
  renamed to _reload to satisfy the unused-variable rule.
- catalog-integrity-panel: same pattern; refresh starts a new read-only scan,
  so the rescan control did nothing.
- catalog-search: refreshKey re-runs the query after a bulk edit, so results
  were not refreshed after catalog edits. The selection-reset effect also lost
  catalogType, so switching catalog no longer cleared the selection.
- catalog-image-picker: dropped debounced (the search term) and name (the
  error reset), so image search and error state no longer reacted to input.
- icon-picker: dropped iconImage, so a failed load left the placeholder on the
  next icon too.

Each restored dependency carries a biome-ignore with the reason it is
load-bearing, so the diagnostic can be re-derived instead of silently
disappearing again.

Verified: typecheck, lint clean on all six, unit 3315 passed, integration 20
passed, UI 72 passed / 2 skipped.
2026-10-03 18:07:56 +02:00
openhands eddb7edea4 fix: make all CI jobs pass (integration, ui) and restore prefix dialog reset
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m35s
CI / tests-ui (push) Successful in 2m20s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m37s
Three failing test suites blocked CI. All three were test defects, not
application bugs.

Integration tests (integration/database.test.ts)
------------------------------------------------
The suite set NODE_ENV=test, which makes cache.cached() short-circuit both
its Redis read (src/lib/cache.ts:226) and its write (:249). A suite whose
stated purpose is exercising the real Redis path therefore never touched
Redis. Switched to NODE_ENV=development, the only non-production value
src/env.ts accepts, so the shared-cache code paths are genuinely covered.

Three assertions then needed correcting for real Redis semantics:

- `await cache.cached(...)` followed by `.resolves` can never hold: await
  yields a value, not a Promise. Assert the value directly.
- A cached negative result is stored as the JSON encoding of null, so
  `redis.get(key)` returns "null", not null.
- The news negative-cache key does not exist at all, so `ttl()` returned -2.
  Now that the write path is live the key is created and the TTL assertion
  holds as originally written.

UI tests (src/app/admin/prefixes/prefix-dialog.tsx)
---------------------------------------------------
The form-reset effect had `isOpen` removed from its dependency array. The
component returns null when closed, so the effect only ever ran on mount:
reopening the dialog no longer cleared the fields and a dismissed-but-
unsaved edit reappeared. Two tests in e2e/ui/unsaved-changes.spec.ts caught
this. Restored the dependency and documented why it is load-bearing.

The remaining edits in this branch drop stale biome-ignore comments that
suppressed useExhaustiveDependencies and noArrayIndexKey diagnostics. Where
the suppression had been load-bearing for behaviour, the underlying
dependency is now listed explicitly rather than silenced.

Verified: check (toolchain, audit, lint, i18n, typecheck), unit 3315
passed, integration 20 passed, UI 72 passed / 2 skipped.
2026-10-03 17:02:49 +02:00
openhands 1c9ddcd48a fix(cms): increase docker mem limit to 6gb and enforce node max-old-space-size to prevent OOM killer crashes
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m30s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
2026-10-02 22:25:16 +02:00
257 changed files with 9770 additions and 3320 deletions

No files matched your search

+6
View File
@@ -33,6 +33,12 @@ jobs:
- name: Toolchain check
run: node scripts/check-node-toolchain.mjs
# Port selection decides which blue/green slot stays live. Getting it
# wrong starts the candidate on an occupied port, so the regression that
# caused a failed deploy is covered here, before any image is built.
- name: Deploy port-selection tests
run: bash scripts/ci-deploy-ports.test.sh
- name: Install dependencies
run: pnpm install --frozen-lockfile
+7
View File
@@ -1,5 +1,12 @@
image: node:26
# Runner containers have no systemd, so scripts/with-memory-cap.sh cannot
# enforce an RSS cap there and correctly refuses to run unbounded. These
# builds are already isolated inside their own runner container (not the
# host) and use the webpack builder; opt out explicitly on purpose.
variables:
CMS_MEMORY_CAP_BACKEND: "none"
stages:
- test
- build
+22 -4
View File
@@ -3,10 +3,12 @@ FROM node:26.10.0-alpine AS migrations
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Installeer git en pnpm v12
# Installeer git, bash en pnpm v12. bash is nodig voor
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
# Alpine levert geen bash mee.
RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache git \
&& npm install -g pnpm@12.8.1
apk add --no-cache git bash \
&& npm install -g pnpm@12.10.1
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
ENV PNPM_HOME="/usr/local/share/pnpm"
@@ -26,6 +28,22 @@ FROM migrations AS builder
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# The build runs the webpack builder (see the `build` script in package.json).
# Turbopack's compiler is a single native process that grows past 12GB RSS on
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
# --max-old-space-size cap does NOT help, because that memory is native
# Turbopack memory rather than the V8 heap.
#
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
# explicitly opts out of the cap. The real bound here is the webpack builder
# + the V8 heap cap above, and the build runs isolated in its own container,
# not on the host; the host itself is protected by the same wrapper through
# systemd.
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV CMS_MEMORY_CAP_BACKEND=none
# Bouw de Next.js applicatie met caching
RUN --mount=type=cache,target=/app/.next/cache \
DATABASE_URL="mysql://build:[email protected]:9/build" \
@@ -57,4 +75,4 @@ EXPOSE 3002
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "docker-start.mjs"]
CMD ["node", "docker-start.mjs"]
+20 -2
View File
@@ -875,6 +875,24 @@ that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release.
### Compose on a CI host
Compose and CI both want port 3002, so only one of them can own a host. A stray
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
green slot, and every later release stopped on "Port 3002 is already in use" —
after the build, the migrations and the browser gate. Two guards now prevent
that:
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream.
- `ci-deploy.sh` retires a compose replica of *this* checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
currently serves. Anything else still fails loudly in `assert_port_free`.
### The nginx upstream is the switch
nginx does not know about container names; it reads a plain list of backends from
@@ -951,7 +969,7 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm analyze` | Build + report per-route bundle sizes |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm biome:check` | Lint and format code |
@@ -1080,7 +1098,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite
pnpm analyze # Build and analyze bundle sizes
pnpm analyze # Build and report per-route bundle sizes
pnpm biome:check # Lint and format
```
+24
View File
@@ -38,6 +38,30 @@
}
}
}
},
{
"includes": [
"src/components/admin/catalog-manager/sortable-tree.tsx",
"src/components/admin/studio/organize-imports-dialog/mall-helpers.tsx",
"src/app/admin/import/furni/nitro-editor-dialog.tsx"
],
"linter": {
"rules": {
"suspicious": {
"noArrayIndexKey": "off"
}
}
}
},
{
"includes": ["src/components/admin/catalog-manager/sortable-tree.tsx"],
"linter": {
"rules": {
"correctness": {
"useExhaustiveDependencies": "off"
}
}
}
}
],
"css": {
+30 -1
View File
@@ -162,6 +162,22 @@ server {
ssl_early_data on;
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
# Resuming a session skips the full handshake, which is most of the cost of
# a TLS connection. Without this nginx performs no session resumption at all:
# every visitor paid a full handshake on every request. 50M shared sessions
# is roughly 1GB at the default 20-byte key id plus overhead.
ssl_session_cache shared:CMS_TLS:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# `index index.html` without a `root` left nginx resolving every
# try_files/$uri against the compiled-in default /etc/nginx/html. The
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
# traverse, and because a failed stat is logged at crit the error log filled
# with 149 crit lines per scan. Pointing root at the CMS document root makes
# the same probe a plain 404, which log_not_found already suppresses.
root /var/www/html;
index index.html;
# ─── Security Headers ───
@@ -366,8 +382,21 @@ server {
add_header Cache-Tag "cms-camera";
}
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
# location used to answer from disk with try_files, which made it a
# guaranteed 404: the file does not exist in public/, so crawlers were told
# to obey a robots.txt they could never read. Proxy it like the route it
# actually is. favicon.ico below stays on disk — log_not_found already
# keeps its miss quiet.
location = /robots.txt {
access_log off;
proxy_pass http://cms_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
# ─── Static Next.js Assets ───
location /_next/static/ {
@@ -0,0 +1,39 @@
[Unit]
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
# and health probes). This is NOT optional: a web process alone does not
# establish that scheduled work runs. The CMS reports it as a failed
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
# missing, which is exactly what happened while nothing supervised this.
#
# It runs on the host rather than in a container on purpose: the schedule
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
# and it must survive CMS deploys (a container is replaced on every release).
Description=AtomNext CMS scheduled-job worker
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
After=network-online.target docker.service mariadb.service
Wants=network-online.target
# Start ordering only; the worker tolerates the database being briefly absent
# and retries, so do not make it hard-fail when mariadb is slow to boot.
Wants=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/var/www/atom-nexst
Environment=NODE_ENV=production
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
# bad DATABASE_URL) from spinning.
Restart=always
RestartSec=10
# Give a crashed job time to finish its DB transaction before the next start,
# otherwise a mid-transaction kill can loop on the same failure.
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cms-jobs-worker
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
[Service]
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
# inherits that soft limit, so worker_connections 2048 could not actually be
# reached and every start logged:
# "2048 worker_connections exceed open file resource limit: 1024"
# Raise both soft and hard to 65536 so the master's rlimit covers
# worker_connections before nginx is even started.
LimitNOFILE=65536
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
# down until someone noticed. nginx is the only thing serving the site, so it
# must come back on its own. `on-failure` restarts only abnormal exits, which
# keeps an operator-initiated `systemctl stop` from being undone.
Restart=on-failure
RestartSec=2
# Give in-flight requests time to drain on stop/reload instead of severing
# keepalive connections and long-polling SSE streams mid-response.
TimeoutStopSec=30
+3 -40
View File
@@ -1,18 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# Next.js CMS — blue/green
#
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
#
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
# nieuwe replica, dan blijft de oude gewoon draaien.
#
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
# afwijkende velden opnieuw in.
# ─────────────────────────────────────────────────────────────────────────────
x-cms: &cms
image: epicnext-cms:${CMS_RELEASE:-local}
@@ -23,8 +10,6 @@ x-cms: &cms
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
network: host
network_mode: host
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
# volgt. Met 10s werden streams en imports afgekapt.
stop_grace_period: 15s
restart: unless-stopped
env_file:
@@ -36,20 +21,11 @@ x-cms: &cms
- /var/www/Gamedata:/var/www/Gamedata
# ── Resource limits ──
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
# volledig door twee replicas worden opgeëist.
mem_limit: 4g
memswap_limit: 5g
mem_limit: 6g
memswap_limit: 7g
cpus: 2.0
pids_limit: 512
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s
@@ -58,7 +34,6 @@ x-cms: &cms
start_period: 40s
services:
# Blauwe replica: host-poort 3002.
cms:
<<: *cms
container_name: epicnext-cms
@@ -66,8 +41,6 @@ services:
- HOSTNAME=0.0.0.0
- PORT=3002
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
cms-green:
<<: *cms
container_name: epicnext-cms-green
@@ -76,7 +49,6 @@ services:
- HOSTNAME=0.0.0.0
- PORT=3003
# ── Byparr (Cloudflare bypass for clone sources) ──
byparr:
image: ghcr.io/thephaseless/byparr:latest
container_name: byparr
@@ -84,19 +56,10 @@ services:
restart: unless-stopped
environment:
- LOG_LEVEL=INFO
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
pids_limit: 256
healthcheck:
test: ["CMD", "curl", "http://localhost:8191/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
# dus al langer niets meer in beheer.
start_period: 30s
+11 -3
View File
@@ -12,17 +12,25 @@ The command writes `report.json` and `report.md` and prints the Markdown report.
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded.
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
## Initial limits
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --- | ---: | ---: | ---: | ---: |
@@ -0,0 +1,31 @@
-- Repair the escalation introduced by 0018's rule 1 ("has admin.dashboard gets
-- ALL admin.*"). Migrating 0011 grants admin.dashboard to every rank >= 6 so
-- that the sidebar opens, which meant rank 6 silently acquired
-- admin.permissions.manage, admin.rcon.execute, admin.settings.edit,
-- admin.users.edit, admin.users.reset_password, admin.room.delete, ...
--
-- Rule 1 is narrowed to `admin.%.view` (read-only, all the sidebar needs) in
-- both the migration set and the runtime repair action. This migration undoes
-- the over-grant on databases that already ran 0018: every role below the top
-- rank keeps dashboard + *.view and loses every other admin.* grant. Ranks
-- that legitimately hold tools keep them, because rule 3 only targets
-- rank >= 7 and those roles are not touched here.
--
-- Note on the rank extraction: `acl_roles.slug` looks like `rank_7`, and
-- MySQL's SUBSTRING is 1-based, so the digits start at position 6 — right
-- after the 5-character `rank_`. Reading from position 7 truncates the first
-- digit, which turns rank_10 into 0 and rank_7 into an empty string, i.e. both
-- would compare as < 7 and lose grants this migration is supposed to preserve.
-- The REGEXP guard below guarantees the remainder really is all digits.
DELETE `amp`
FROM `acl_model_permissions` `amp`
JOIN `acl_roles` `ar`
ON `ar`.`id` = `amp`.`model_id`
AND `amp`.`model_type` = 'Role'
JOIN `acl_permissions` `ap`
ON `ap`.`id` = `amp`.`permission_id`
WHERE `ap`.`slug` LIKE 'admin.%'
AND `ap`.`slug` NOT LIKE '%.view'
AND `ar`.`slug` REGEXP '^rank_[0-9]+$'
AND CAST(SUBSTRING(`ar`.`slug`, 6) AS UNSIGNED) < 7;
@@ -0,0 +1,19 @@
-- 0035_users_mail_index.sql
-- Index on users.mail.
--
-- The authentication paths all look an account up by mail: password reset,
-- e-mail verification, duplicate-address detection and the verify/resend
-- cooldown all resolve a single user from a submitted address. Without an index
-- each of those is a full table scan of `users`, which grows with every
-- registration.
--
-- Deliberately NOT unique. Legacy rows predate the duplicate-address handling
-- and can legitimately contain the same address more than once, so a unique
-- index would fail to apply on an existing database. The lookup is made
-- deterministic by ordering on `id` (see requestReset / the verify page), which
-- is stable without the index and correct with it.
--
-- The column is VARCHAR(500), which exceeds the 767-byte InnoDB prefix limit on
-- older row formats, hence an explicit 191-character prefix: enough to make the
-- lookup selective and still indexable everywhere.
CREATE INDEX IF NOT EXISTS `users_mail_index` ON `users` (`mail`(191));
+4 -1
View File
@@ -88,7 +88,10 @@ test("staff signs in, saves a draft, previews it and publishes to anonymous read
await page
.locator('input[autocomplete="current-password"]')
.press("Enter");
await expect(page).toHaveURL(/\/me(?:\?|$)/);
// The login page honours `?from=`, so an admin bounced off /admin lands
// back where they were heading instead of on /me. The step below
// navigates there explicitly anyway; this asserts the redirect target.
await expect(page).toHaveURL(/\/admin\/articles\/new(?:\?|$)/);
const session = await context.request
.get("/api/auth/session")
.then((response) => response.json());
+19 -6
View File
@@ -156,7 +156,14 @@ beforeAll(async () => {
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
delete process.env.SKIP_ENV_VALIDATION;
delete process.env.OPENAI_API_KEY;
Object.assign(process.env, { NODE_ENV: "test" });
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
// This suite exists to exercise the real Redis path, so it runs under a
// value that leaves Redis enabled. "development" is used because it is the
// only non-production value src/env.ts accepts. Vitest's own environment is
// still configured via vitest.integration.config.ts. Object.assign is used
// because process.env.NODE_ENV is typed read-only.
Object.assign(process.env, { NODE_ENV: "development" });
process.env.HOTEL_NAME = "Integration";
await connection.query(
@@ -380,9 +387,8 @@ describe("Redis application cache", () => {
let fetches = 0;
const fetch = async () => ({ revision: ++fetches });
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
expect(
await cache.cached(key, 60000, async () => ({ revision: 1 })),
).resolves.toMatchObject({ revision: 1 });
// Second read is served from cache, so the origin is not consulted again.
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
cache.invalidateMemory(key);
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
@@ -403,6 +409,9 @@ describe("Redis application cache", () => {
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
"updated",
);
// `second`'s memory copy was dropped too, but its Redis entry survives, so
// the read is served from the shared cache and never recomputes. This is
// what makes the two entries independent.
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
"second",
);
@@ -620,9 +629,12 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(existing.status).toBe("draft");
expect(existing.publishedAt).toBeNull();
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
// A draft has no public article, so this read is a negative result that
// gets cached. Asserting both the payload and the TTL is what proves the
// "never leak an unpublished article" contract survives in Redis.
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
expect(await appRedis?.get(negativeKey)).toBeNull();
expect(await appRedis?.get(negativeKey)).toBe("null");
expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0);
const body = `<p>${"Contenuto completo è 📰 ".repeat(4000)}</p>`;
@@ -839,7 +851,8 @@ describe("real news publication, scheduling and cache delivery", () => {
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
expect(await redis.get(negativeKey)).toBeNull();
// Cached negative results are stored as the JSON encoding of null.
expect(await redis.get(negativeKey)).toBe("null");
const publish = articleForm({
id: String(existing.id),
baseToken: articleEditToken(existing),
+25 -25
View File
@@ -5,10 +5,10 @@
"engines": {
"node": ">=26.10.0 <27"
},
"packageManager": "pnpm@12.8.1",
"packageManager": "pnpm@12.10.1",
"scripts": {
"dev": "pnpm assets:editor && next dev",
"build": "pnpm assets:editor && next build",
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
"start": "next start",
"toolchain:check": "node scripts/check-node-toolchain.mjs",
"lint": "biome check .",
@@ -16,9 +16,9 @@
"format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "vitest run --coverage.enabled=false",
"test:coverage": "vitest run",
"typecheck": "tsc --noEmit",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts",
@@ -30,25 +30,25 @@
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "next experimental-analyze",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "playwright test",
"test:news:real": "node --import tsx e2e/news-real/run.ts",
"test:ui": "playwright test --config playwright.ui.config.ts",
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs",
"test:integration": "vitest run --config vitest.integration.config.ts"
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
},
"dependencies": {
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.20",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.1",
"@tanstack/react-virtual": "3.14.13",
@@ -59,16 +59,16 @@
"drizzle-orm": "0.45.3",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
"isomorphic-dompurify": "^4.4.0",
"isomorphic-dompurify": "^4.5.0",
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.50.0",
"lucide-react": "1.52.0",
"lzma-wasm": "1.0.7",
"motion": "14.0.0",
"music-metadata": "11.16.1",
"music-metadata": "12.0.0",
"mysql2": "3.24.5",
"next": "16.3.8",
"next": "16.4.0",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
@@ -76,17 +76,17 @@
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.89.0",
"resend": "6.32.0",
"resend": "6.32.1",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0",
"tinymce": "8.9.2",
"tinymce": "8.9.3",
"zod": "4.6.5"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "7.29.9",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.63.0",
"@tailwindcss/forms": "0.5.11",
@@ -98,14 +98,14 @@
"@vitest/coverage-v8": "5.0.3",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "3.0.1",
"pino-pretty": "13.1.3",
"postcss": "8.5.28",
"msw": "^2.15.0",
"pino-pretty": "13.2.0",
"postcss": "8.5.29",
"tailwindcss": "4.3.3",
"testcontainers": "12.2.0",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.2",
"vite": "8.3.3",
"vitest": "5.0.3"
}
}
+360 -371
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -16,3 +16,4 @@ overrides:
glob: '^11.0.0'
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2'
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+21 -4
View File
@@ -45,11 +45,28 @@ for (const image of nodeImages) {
);
}
const cmpVersion = (a, b) => {
const pa = a.split(".").map((part) => Number.parseInt(part, 10));
const pb = b.split(".").map((part) => Number.parseInt(part, 10));
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
};
// `.nvmrc` is the recommended version for reproducible local development and
// the exact Docker base image (both asserted above), but the *runtime* we run
// on may be any version the package.json engines range accepts. Requiring an
// exact patch match here would fail on every Node.js patch release, even when
// the version is explicitly supported.
if (!process.argv.includes("--static")) {
assert.equal(
process.versions.node,
pinnedVersion,
"the active Node.js runtime must match .nvmrc",
const active = process.versions.node;
const upperBound = `${major + 1}.0.0`;
assert.ok(
cmpVersion(active, pinnedVersion) >= 0 &&
cmpVersion(active, upperBound) < 0,
`the active Node.js runtime (${active}) must satisfy package.json engines.node (${packageJson.engines.node}); .nvmrc pins ${pinnedVersion} as the recommended version`,
);
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
#
# Background: on a host where both blue/green slots answer /api/health, the
# original read_active_port() counted healthy slots and only consulted the nginx
# upstream when the count was not exactly 1. With two healthy slots it fell back
# to the upstream file, but an operator `docker compose up` can leave an extra
# replica behind, after which the fallback picked slot A regardless of which slot
# was really live. The candidate then tried to start on an occupied port, and the
# health probe answered from the pre-existing container on that port instead of
# the candidate — producing 30 failed "expected release never became healthy"
# attempts against a release that was never serving.
#
# The functions are extracted from ci-deploy.sh rather than copied so this test
# cannot drift from the script it protects.
set -Eeuo pipefail
deploy_script="$(dirname "$0")/ci-deploy.sh"
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
# Pull the two functions out of the real script.
extract() {
sed -n "/^$1() {/,/^}/p" "$deploy_script"
}
read_active_port_fn="$(extract read_active_port)"
assert_port_free_fn="$(extract assert_port_free)"
answers_health_fn="$(extract answers_health)"
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
echo "could not extract functions from $deploy_script" >&2
exit 1
fi
slot_a_port=3002
slot_b_port=3003
fail() { echo "FAIL: $*" >&2; exit 1; }
# ── read_active_port ──────────────────────────────────────────────────────────
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
run_read_active_port() {
local body="$1" a="$2" b="$3" tmp
tmp="$(mktemp)"
if [ "$body" = "none" ]; then
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
rm -f "$tmp"
else
printf '%s\n' "$body" >"$tmp"
fi
CMS_UPSTREAM_FILE="$tmp" \
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
bash -c "
slot_a_port=$slot_a_port
slot_b_port=$slot_b_port
upstream_file=\"\$CMS_UPSTREAM_FILE\"
$read_active_port_fn
# Defined after the extracted function on purpose: answers_health is a
# collaborator here, and the test substitutes a deterministic stub for it.
answers_health() {
local p=\$1 want
case \$p in
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
*) want='' ;;
esac
[ \"\$want\" = yes ]
}
read_active_port
echo
" 2>/dev/null
rm -f "$tmp"
}
# nginx points at slot B and both answer -> trust the upstream file.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
# The regression: both healthy, nginx points at B, but slot A is an unrelated
# leftover replica. The upstream file is the only thing that knows which slot is
# live, so it must win.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
# Upstream names a dead slot: fall back to a slot that actually answers, never to
# the dead port itself.
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
# rollback path has no target.
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
# No upstream file at all: pick a slot that answers.
got="$(run_read_active_port none no yes)"
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
got="$(run_read_active_port none yes no)"
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
# ── assert_port_free ─────────────────────────────────────────────────────────
# Runs against real loopback ports: 3999 is intentionally unused, so the check
# must report it free.
bash -c "
$assert_port_free_fn
assert_port_free 3999 candidate >/dev/null 2>&1
" || fail "a port with no listener must be reported as free"
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
# it genuinely is free, otherwise the assertion would be meaningless.
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
if bash -c "
$assert_port_free_fn
assert_port_free 3002 candidate >/dev/null 2>&1
"; then
fail "an occupied port must be rejected, but assert_port_free returned success"
fi
fi
echo 'Deploy port-selection tests passed'
+183 -30
View File
@@ -76,6 +76,13 @@ healthy() {
return 1
}
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
# een al draaiend proces dat nu of nooit antwoordt.
answers_health() {
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
}
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
# niet stilvalt op een upgrade.
@@ -85,29 +92,156 @@ detect_blue_green() {
return 0
}
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
# Welke poort is op dit moment ÉCHT live?
#
# Volgorde van vertrouwen:
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
#
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
# container die toevallig een andere release draaide.
read_active_port() {
local live="" port="" result=""
local count=0
for port in "$slot_a_port" "$slot_b_port"; do
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
live="$live $port"
fi
done
for port in $live; do count=$((count + 1)); result="$port"; done
if [ "$count" -eq 1 ]; then
printf '%s' "$result"
local port="" pointed=""
if [ -r "$upstream_file" ]; then
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
fi
if [ -n "$port" ] && answers_health "$port"; then
printf '%s' "$port"
return 0
fi
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
case "$port" in
"$slot_b_port") printf '%s' "$slot_b_port" ;;
*) printf '%s' "$slot_a_port" ;;
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
# enige andere gezonde slot, anders is er niets om op te bouwen.
for candidate in "$slot_a_port" "$slot_b_port"; do
[ "$candidate" = "$port" ] && continue
if answers_health "$candidate"; then
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
printf '%s' "$candidate"
return 0
fi
done
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
# rollback-poging toch het vorige slot kan starten.
if [ -n "$port" ]; then
printf '%s' "$port"
return 0
fi
printf '%s' "$slot_a_port"
}
# Poort-bezetting controleren vóór het starten van de kandidaat.
#
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
# container op diezelfde poort beantwoordt. Dat levert een misleidende
# "expected release never became healthy" op in plaats van de echte oorzaak.
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
# hem vasthoudt.
assert_port_free() {
local port="$1" name="$2"
local holders=""
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
# hoeft te zien.
if type ss >/dev/null 2>&1; then
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
fi
[ -z "$holders" ] && return 0
echo "Port $port is already in use, cannot start candidate $name" >&2
printf '%s\n' "$holders" >&2
# Noem exact het container dat de poort vasthoudt.
#
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
# onschuldige containers begraven.
local squatter="squatter_pids"
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
if [ -n "$squatter_pids" ]; then
local pid cid owner=""
for pid in $squatter_pids; do
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
[ -n "$cid" ] || continue
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
done
fi
echo "" >&2
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
# naam die toevallig op een van deze poorten draait — meestal een
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
# dus niet geraakt.
case "$owner" in
*"/$name"*|*"/$slot_b_container"*)
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
*)
cat >&2 <<EOF
This port is held by a container that is not a blue/green slot, so the deploy
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
the other slot until cutover.
Remove the stray container and re-run the deploy:
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
If it comes back after a reboot, it is started by docker-compose.yml rather
than by this script; delete or disable that service.
EOF
;;
esac
return 1
}
# Ruim een compose-replica op die een blauwe/groene slot bezet.
#
# Een `docker compose up` — of de dagelijkse `scripts/docker-update.sh`, waarvan
# de CI-eigendomscontrole per slot wankelde — laat een replica met container_name
# `epicnext-cms` achter op poort 3002. Die draait nooit live: nginx wijst naar de
# poort van een slot-container die dit script zelf heeft gestart, en die staat per
# definitie aan de andere kant dan de kandidaat. Zonder deze opruimstap loopt elke
# release vast op een bezette poort totdat iemand de container met de hand
# verwijdert.
#
# Bewust smal, want een container van een ander deployment is niet van ons:
# - alleen een replica die uit precies deze checkout komt
# (com.docker.compose.project.config_files), niet een losse compose-project;
# - nooit een slot-container, want die beheert dit script zelf;
# - nooit de poort waar nginx naar wijst.
# Wat daarnaast nog op de doel-poort zit, laat assert_port_free() met zijn eigen
# foutmelding staan in plaats van stilzwijgend verdwijnen.
retire_compose_replicas() {
local live_port="$1" cid name="" config_files="" port=""
while read -r cid; do
[ -n "$cid" ] || continue
name="$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed -n 's#^/##p' || true)"
case "$name" in ''|"$slot_a_container"|"$slot_b_container") continue ;; esac
config_files="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$cid" 2>/dev/null || true)"
[ "$config_files" = "$deploy_dir/docker-compose.yml" ] || continue
port="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null | sed -n 's#^PORT=##p' | head -1 || true)"
[ -n "$port" ] && [ "$port" != "$live_port" ] || continue
echo "Removing compose replica $name on port $port: it squats a blue/green slot and is not the live release (nginx serves $live_port)"
docker rm -f "$name" || return 1
done < <(docker ps --filter "label=com.docker.compose.project.config_files=$deploy_dir/docker-compose.yml" --format '{{.ID}}')
return 0
}
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
@@ -153,6 +287,7 @@ switch_upstream() {
# gelden.
start_candidate() {
local port="$1" name="$2"
assert_port_free "$port" "$name"
(
set -a
# shellcheck disable=SC1091
@@ -183,7 +318,7 @@ finish() {
if [ "$cutover_started" -eq 0 ]; then
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
echo "Deployment failed before cutover; the live release was never stopped" >&2
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
docker logs "$new_container" --tail 50 >&2 || true
docker rm -f "$new_container" || true
fi
@@ -191,9 +326,9 @@ finish() {
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
if healthy "$old_port"; then
echo "Rollback verified on port $old_port"
@@ -338,15 +473,28 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
exit 1
fi
# The avatar/badge disk cache lives on the host bind and is written by uid 33
# inside the container. Root-owned directories make every cache write fail
# silently, which turns each avatar into a fresh live render.
# The application writes everything under storage/ as uid 33, but storage is a
# host bind so the image's own ownership is irrelevant. Any path that is not
# uid 33 makes the write fail with EACCES, and because most of these writes are
# inside a try/catch the failure is silent: the avatar cache just never fills
# (each avatar becomes a fresh live render) and the catalog export reports
# "delivery failed" while the emulator never receives the update. The old code
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
nitro-cleanup config-backups nitro-scale32-backups; do
target="$deploy_dir/storage/$owned_dir"
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
[ -d "$target" ] || continue
chown -R 33:33 "$target" 2>/dev/null || true
done
# The avatar/badge cache needs its leaf directories to exist before first use;
# the cache misses (and re-renders live) rather than erroring when they do not.
for cache_dir in avatars badges; do
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
fi
done
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
if [ "$blue_green" -eq 1 ]; then
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
@@ -356,23 +504,28 @@ if [ "$blue_green" -eq 1 ]; then
docker rm -f "$new_container"
fi
# 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
# niet op een bezette poort stukloopt.
retire_compose_replicas "$old_port"
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
candidate_attempted=1
start_candidate "$new_port" "$new_container"
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# 4. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
# cutover de productie breken in plaats van ervoor.
healthy "$new_port"
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 4. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
cutover_started=1
switch_upstream "$new_port"
echo "Cut over to port $new_port; retiring port $old_port"
# 5. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
# 6. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
# waarop er geen enkele container draait.
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
docker stop "$old_container"
+94 -2
View File
@@ -3,15 +3,21 @@
#
# Modes:
# (default) — post-deploy cleanup (safe, fast):
# - Build cache older than 72h, capped at 4 GB max used space.
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
# least-recently-used entries. This cap is the actual bound.
# - Unreferenced images older than 7 days (keeps rollback images around).
# - Stopped containers older than 24h.
# - Dangling images, which are always unreferenced.
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
# --force — emergency mode ("never let the disk max out"): drops everything
# with no age windows:
# - ALL unreferenced build cache,
# - ALL unreferenced images (no age grace),
# - ALL stopped containers.
#
# The default mode escalates to --force on its own when / drops below 8 GB free,
# so the bound holds even if this stops running on schedule.
#
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
# Idempotent; exits 0 when Docker is unavailable.
set -Eeuo pipefail
@@ -34,15 +40,101 @@ command -v docker >/dev/null 2>&1 || {
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
# A hard ceiling on the root filesystem is what actually bounds the growth, so
# the emergency path is reached on disk pressure rather than only on a timer.
# The image/container passes stay age-gated: a rollback image and a stopped
# container are cheap to keep for a week and expensive to lose.
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
# 8 GB free is comfortable for a database plus a release swap.
if (( FREE_KB < 8 * 1024 * 1024 )); then
FORCE=1
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
"$(now)" "$FREE_KB" >>"$LOG_FILE"
fi
if (( FORCE )); then
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
docker image prune -af >>"$LOG_FILE" 2>&1 || true
docker container prune -f >>"$LOG_FILE" 2>&1 || true
else
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
# --max-used-space and --filter are mutually exclusive in buildx: passing
# both makes the cap a no-op and the cache grows without bound. The cap alone
# is the bound, and it evicts least-recently-used entries to get there.
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
fi
# Dangling images have no tag and no container, so nothing can reference them.
# They are what repeated local builds leave behind.
docker image prune -f >>"$LOG_FILE" 2>&1 || true
# ── Interrupted git gc leftovers ─────────────────────────────────
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
# nothing reclaims it: git only clears those on the next successful gc. One such
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
# than a day are considered, so a gc running right now is never touched.
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
while IFS= read -r -d '' tmp; do
size=$(du -h "$tmp" | cut -f1)
rm -f "$tmp"
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
fi
# ── Orphaned browser profiles ─────────────────────────────────────
# byparr launches a real Firefox per request, and each launch leaves a
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
# days on this host, ~1.7 GB/day.
#
# Deleting a profile out from under a running browser kills that job, so live
# ones are identified the only way that is reliable rather than by age: a
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
# pointing into the directory. Anything not referenced that way, and untouched
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
#
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
# age window that is safe for the leak is far too wide for the disk.
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
for container in ${BYPARR_CONTAINERS:-byparr}; do
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
removed=$(
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
set -u
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
base="${1:-/tmp}"
live_file=$(mktemp)
# Live profiles are the ones a running process still holds open.
for p in $(ps -eo pid= 2>/dev/null); do
ls -l "/proc/$p/fd" 2>/dev/null
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
count=0
for dir in "$base"/playwright_firefoxdev_profile-*; do
[ -d "$dir" ] || continue
# Never touch something a process is still using.
grep -Fxq "$dir" "$live_file" && continue
# A profile a browser is still writing to is not an orphan
# yet, even if the directory itself looks old.
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
continue
fi
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
done
rm -f "$live_file"
printf "%s" "$count"
' sh /tmp 2>/dev/null || printf '0'
)
if [[ "${removed:-0}" -gt 0 ]]; then
printf '[%s] removed %s orphaned browser profiles from %s\n' \
"$(now)" "$removed" "$container" >>"$LOG_FILE"
fi
done
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
docker system df >>"$LOG_FILE" 2>&1 || true
+93 -1
View File
@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { it } from "vitest";
import { describe, expect, it } from "vitest";
import {
detectMemoryLimitMb,
heapLimitMb,
runtimeNodeOptions,
} from "./docker-start.mjs";
it("imports runtime validation without starting the CMS", () => {
const result = spawnSync(
@@ -15,3 +21,89 @@ it("imports runtime validation without starting the CMS", () => {
);
assert.equal(result.status, 0, result.stderr);
});
describe("heap limit", () => {
it("leaves headroom for the memory V8 does not account for", () => {
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
});
it("clamps to a floor and a ceiling", () => {
// Too small to run a Next.js server at all: floor wins.
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
// A huge or absent limit must not turn into a 100 GB heap.
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
expect(heapLimitMb(Number.NaN)).toBe(8192);
expect(heapLimitMb(0)).toBe(8192);
});
});
describe("cgroup detection", () => {
const asReader = (contents) => (path) => {
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
return contents[path];
};
it("reads the cgroup v2 limit", () => {
expect(
detectMemoryLimitMb(
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
),
).toBe(2867);
});
it("falls back to cgroup v1 when v2 is absent", () => {
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory.max": "",
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
}),
),
).toBe(4300);
});
it("treats an unlimited cgroup as no limit at all", () => {
// cgroup v1 reports "max"; a bare sentinel means the same thing.
expect(
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
).toBe(8192);
expect(
detectMemoryLimitMb(
asReader({
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
}),
),
).toBe(8192);
});
it("falls back when neither cgroup file is readable", () => {
expect(
detectMemoryLimitMb(() => {
throw new Error("ENOENT");
}),
).toBe(8192);
});
});
describe("NODE_OPTIONS", () => {
it("adds the cap when none is set", () => {
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
expect(runtimeNodeOptions(undefined, 2867)).toBe(
"--max-old-space-size=2867",
);
});
it("keeps unrelated options already present", () => {
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
"--no-warnings --max-old-space-size=2867",
);
});
it("never overrides an explicit operator choice", () => {
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
"--max-old-space-size=8192",
);
});
});
+70 -1
View File
@@ -1,7 +1,70 @@
// Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
/** Fraction of the container memory limit V8 is allowed to use for its heap.
* The rest has to cover native allocations the JS heap cannot account for:
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
* RSC rendering. */
const HEAP_FRACTION = 0.7;
const MIN_HEAP_MB = 512;
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
* for 4300 MB, and a backstop at or below that would silently turn the fraction
* into a fixed number and make the two limits disagree. This exists purely so a
* nonsensical cgroup reading cannot ask for an unbounded heap. */
const MAX_HEAP_MB = 8192;
export function heapLimitMb(cgroupLimitBytes) {
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
return MAX_HEAP_MB;
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
}
/**
* Read this container's memory ceiling from cgroup v2, falling back to v1.
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
* container on a 24 GB host lets the heap grow past the limit and the kernel
* OOM-kills the process mid-request — which is what produced the
* `next-build (v16)` kills in the host logs. A container that GCs before it
* reaches the ceiling degrades to a slower page instead of a killed process.
*/
export function detectMemoryLimitMb(readFile = readFileSync) {
const candidates = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
for (const path of candidates) {
let raw;
try {
raw = readFile(path, "utf8").trim();
} catch {
continue;
}
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
// sentinel of a very large number on some kernels.
if (raw === "max" || raw === "") continue;
const bytes = Number(raw);
if (!Number.isFinite(bytes) || bytes <= 0) continue;
// A host-sized "limit" means no cgroup ceiling was applied.
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
return heapLimitMb(bytes);
}
return heapLimitMb(Number.NaN);
}
export function runtimeNodeOptions(
existing = "",
heapMb = detectMemoryLimitMb(),
) {
const flag = `--max-old-space-size=${heapMb}`;
if (!existing.trim()) return flag;
// Respect an explicit operator override; only add the cap when absent.
if (existing.includes("--max-old-space-size")) return existing;
return `${existing} ${flag}`;
}
export function validateRuntime(settings) {
const invalid = [];
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
@@ -33,7 +96,13 @@ if (
) {
try {
validateRuntime(process.env);
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
const heapMb = detectMemoryLimitMb();
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
const child = spawn(process.execPath, ["server.js"], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => child.kill(signal));
child.on("error", () => {
+20 -3
View File
@@ -58,10 +58,27 @@ trap 'exit 130' INT
trap 'exit 143' TERM
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
# An existing CI deployment is a different owner of the same host port.
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and
# 3003) and one of the two slot containers is always the live release. Compose
# may only run where CI does not.
#
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
# slot the blue container is stopped, renamed and deleted, so the guard stopped
# firing while the host stayed CI-managed. `docker compose up` then recreated a
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
# candidate has to start — and every later release failed on a busy port until
# someone removed that container by hand (see logs/docker-update.cron.log).
# Therefore: both slot containers count, and so does the nginx upstream, which is
# the only thing that still marks the host as blue/green when a slot is idle.
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
fi
for slot_container in epicnext-cms-app epicnext-cms-green; do
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
fi
done
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
+71 -5
View File
@@ -1,9 +1,17 @@
import { drainOperationEffects } from "../src/features/operations/worker";
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
// Must stay the first import. ESM evaluates a module's imports in source
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
// process.env at import time. With this import further down the tree, load-env
// ran *after* the schema validation had already thrown on a missing
// DATABASE_URL, so the worker could only ever start from an environment that
// already exported the config — which is why `pnpm jobs:worker` died
// immediately and nothing supervised it.
import "./load-env";
import * as nodeFs from "node:fs";
import * as nodePath from "node:path";
import { Cron } from "croner";
import { lt, sql } from "drizzle-orm";
import { env } from "../src/env";
import { drainOperationEffects } from "../src/features/operations/worker";
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis";
@@ -18,6 +26,7 @@ import {
diskLevel,
parseDfOutput,
} from "../src/lib/services/disk-usage";
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
import { publishDueArticles } from "../src/lib/services/news-scheduler";
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
import { rcon } from "../src/lib/services/rcon";
@@ -161,13 +170,69 @@ async function checkDiskUsage(): Promise<void> {
}
}
/**
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
* or at a directory of release JARs, because the emulator's own unit file
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
* release filename goes stale on the next emulator upgrade, and a stale path
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
* directory (or a path with a `*`) resolves to the most recently modified JAR,
* matching how the emulator actually picks its build.
*/
export function resolveEmulatorJar(
configuredPath: string,
fs: typeof import("node:fs") = nodeFs,
{ resolve }: typeof import("node:path") = nodePath,
): string | null {
const { existsSync, readdirSync, statSync } = fs;
if (configuredPath.includes("*")) {
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
const pattern = configuredPath.slice(dir.length);
if (!existsSync(dir)) return null;
return (
readdirSync(dir)
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
.map((name: string) => resolve(dir, name))
.filter((path: string) => existsSync(path))
.sort(
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
)[0] ?? null
);
}
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
return configuredPath;
// A directory: take the newest JAR in it.
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
return (
readdirSync(configuredPath)
.filter((name: string) => name.endsWith(".jar"))
.map((name: string) => resolve(configuredPath, name))
.sort(
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
)[0] ?? null
);
}
return null;
}
async function backupEmulatorJar(): Promise<void> {
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
await import("node:fs");
const fs = await import("node:fs");
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
const { resolve } = await import("node:path");
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
if (!jarPath) {
// Configured but unusable: say so once, loudly, instead of every night
// logging an opaque copyFile ENOENT that reads like a permissions bug.
logger.error(
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
);
return;
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
const backupFile = resolve(
env.EMULATOR_BACKUP_DIR,
@@ -179,10 +244,11 @@ async function backupEmulatorJar(): Promise<void> {
}
try {
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
copyFileSync(jarPath, backupFile);
logger.info("Backed up emulator JAR", {
module: "jobs",
backupFile,
source: jarPath,
});
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
+9
View File
@@ -101,6 +101,15 @@ fi
if [[ ! -d /var/log/nginx ]]; then
install -d -o root -g adm -m 750 /var/log/nginx
fi
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
# failed stat at crit, so each crawler probe wrote a crit line.
if [[ ! -d /var/www/html ]]; then
install -d -o root -g root -m 755 /var/www/html
echo "+ created /var/www/html (document root)"
fi
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
[[ -f "$f" ]] || touch "$f"
done
+62 -13
View File
@@ -79,6 +79,27 @@ function filesFrom(values) {
return values.map(normalizeAsset);
}
/**
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
* a real file has to be separated from its id. An id is rejected by
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
* suffix), which makes it a usable filter — but only for ids. A malformed
* *path* must still fail loudly rather than be silently dropped, or a broken
* manifest would quietly under-report a route's real weight.
*/
function assetFilesFromChunkList(values) {
if (!Array.isArray(values))
throw new Error("Unsupported JavaScript chunk list.");
const files = [];
for (const value of values) {
if (typeof value !== "string")
throw new Error("Non-string JavaScript asset.");
if (/^\d+$/.test(value)) continue;
files.push(normalizeAsset(value));
}
return files;
}
export function measureRoute({
budget,
appPath,
@@ -86,18 +107,44 @@ export function measureRoute({
clientManifest,
readAsset,
}) {
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
// not — it only records chunks per client module — so after the build moved
// to webpack (3d828a61) every route reported "unavailable" and the report
// silently stopped measuring anything. Fall back to the same source Next's
// own `static-routes-info` uses for webpack builds.
const entries = clientManifest?.entryJSFiles;
if (!entries || typeof entries !== "object" || Array.isArray(entries))
const webpackModules = clientManifest?.clientModules;
let filesBySource;
let webpackLayout = false;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
filesBySource = Object.entries(entries);
} else if (webpackModules && typeof webpackModules === "object") {
webpackLayout = true;
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
filesBySource = [];
for (const node of Object.values(webpackModules)) {
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
// One shared origin label instead of the module path: the per-chunk
// `sources` list is written into report.json, and webpack records
// absolute node_modules paths for every client module on the route.
filesBySource.push(["client-module", node.chunks]);
}
if (filesBySource.length === 0)
throw new Error(
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
);
} else {
throw new Error(
"entryJSFiles is unavailable; this manifest layout is not supported.",
);
const sourceEntries = Object.keys(entries);
if (
!sourceEntries.some((key) =>
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
)
)
throw new Error("Route page entry is absent from entryJSFiles.");
}
const bootstrap = filesFrom(
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
);
@@ -110,8 +157,9 @@ export function measureRoute({
origins.set(file, sources);
};
for (const file of bootstrap) add(file, "bootstrap");
for (const [entry, values] of Object.entries(entries))
for (const file of filesFrom(values)) add(file, entry);
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
for (const [entry, values] of filesBySource)
for (const file of readChunkList(values)) add(file, entry);
const size = (file, sources) => {
const bytes = readAsset(file);
return {
@@ -248,12 +296,13 @@ export function collectReport(nextDir, config, metadata = {}) {
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
nodeVersion: process.version,
zlibVersion: process.versions.zlib,
manifestFormat: "Next App Router client-reference entryJSFiles",
manifestFormat:
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
definition:
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus every client chunk this route's client-reference manifest lists, including boundary/loading entries. Turbopack exposes these as entryJSFiles; webpack exposes them only through clientModules[*].chunks, so the same envelope is derived from whichever the build emitted. This is emitted file size, not measured browser traffic or a load-time benchmark.",
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
excluded:
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from the manifest's chunk lists; legacy nomodule polyfills are reported separately.",
routes,
};
}
+129
View File
@@ -216,6 +216,135 @@ describe("route JS measurement", () => {
);
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
});
// The regression: after the build moved to webpack (3d828a61) the manifest
// has no entryJSFiles, only clientModules[*].chunks. Every route then
// reported "unavailable" and the report measured nothing at all while still
// exiting zero, so the budgets silently stopped being enforced.
describe("webpack manifests without entryJSFiles", () => {
const webpackManifest = {
clientModules: {
"[project]/src/components/header.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"6726",
"static/chunks/header-entry.js?dpl=abc",
],
},
"[project]/src/app/(site)/news/page.tsx": {
chunks: [
"4269",
"static/chunks/4269-shared.js?dpl=abc",
"7777",
"/_next/static/chunks/page.js?dpl=abc",
],
},
// Async-only modules are recorded with an empty chunk list.
"[project]/src/components/lazy.tsx": { chunks: [] },
},
};
const webpackFiles = {
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
// bootstrap assets must exist or the read fails.
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
"static/chunks/header-entry.js": Buffer.from("header"),
"static/chunks/page.js": Buffer.from("page"),
"static/chunks/polyfill.js": Buffer.from("legacy"),
};
const measure = () =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: webpackManifest,
readAsset: (file) => webpackFiles[file],
});
it("derives the envelope from clientModules and ignores chunk ids", () => {
const row = measure();
expect(row.status).toBe("measured");
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
// are not assets and must not throw or be counted.
expect(row.initial.chunkCount).toBe(5);
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
"static/chunks/4269-shared.js",
"static/chunks/header-entry.js",
"static/chunks/page.js",
"static/chunks/runtime.js",
"static/chunks/shared.js",
]);
// Same bytes as the Turbopack fixture would produce for these files.
expect(row.initial.rawBytes).toBe(
Object.values(webpackFiles)
.filter((b) => !b.includes("legacy"))
.reduce((n, b) => n + b.length, 0),
);
});
it("counts a chunk reached by several client modules only once", () => {
const shared = measure().initial.chunks.find(
(f) => f.path === "static/chunks/4269-shared.js",
);
expect(shared).toBeDefined();
expect(measure().initial.chunkCount).toBe(5);
});
it("does not leak absolute module paths into the report", () => {
const sources = new Set(
measure().initial.chunks.flatMap((chunk) => chunk.sources),
);
// Only the two known origin labels; no node_modules path may appear.
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
});
it("still fails rather than under-reporting a malformed chunk path", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
clientModules: {
x: { chunks: ["static/chunks/../../etc/passwd"] },
},
},
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Unsupported JavaScript asset");
});
it("reports unavailable when webpack recorded no chunks at all", () => {
expect(() =>
measureRoute({
budget,
appPath,
buildManifest,
clientManifest: { clientModules: { x: { chunks: [] } } },
readAsset: (file) => webpackFiles[file],
}),
).toThrow("Neither entryJSFiles nor clientModules");
});
it("prefers entryJSFiles when a manifest carries both", () => {
// A future Next version could emit both; the explicit list wins
// because it is per-segment and therefore the tighter envelope.
const row = measureRoute({
budget,
appPath,
buildManifest,
clientManifest: {
...webpackManifest,
entryJSFiles: {
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
},
},
readAsset: (file) => webpackFiles[file],
});
expect(row.initial.chunkCount).toBe(3);
});
});
it("does not deduplicate shared files across independent cold route totals", () => {
const row = measureRoute({
budget,
+28 -3
View File
@@ -1,10 +1,35 @@
#!/usr/bin/env bash
# Setup cron jobs for maintenance
#
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
# script that pipes one job at a time silently drops every other scheduled job.
# Entries are matched by their command, so re-running this is idempotent.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Adds one schedule line to the crontab unless its command is already present.
add_job() {
local schedule="$1" command
command="${schedule##* }"
local current
current="$(crontab -l 2>/dev/null || true)"
if grep -Fq "$command" <<<"$current"; then
echo "Already scheduled: $command"
return
fi
if [[ -z "$current" ]]; then
printf '%s\n' "$schedule" | crontab -
else
printf '%s\n%s\n' "$current" "$schedule" | crontab -
fi
echo "Scheduled: $schedule"
}
# Run backup every day at 03:00
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
# Run prune every Sunday at 04:00
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
# run would let ~12 GB accumulate before anything reclaimed it.
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
echo "Cron jobs configured."
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Voer een zwaar commando uit onder een harde geheugenplafond.
#
# Waarom dit bestaat:
# De host draait met `vm.overcommit_memory=0` en ZONDER swap. Vraagt een
# proces meer geheugen dan er vrij is, dan geeft de kernel niets weg en
# roept hij meteen de OOM-killer aan. Die kiest zijn slachtoffer over de
# héle machine, niet alleen in het schuldige proces — dus een build kan de
# database, nginx of de live release meenemen.
#
# `next build` op Turbopack groeit op dit 329-route app voorbij 12GB RSS
# en is ook met 4GB swap nog steeds dood. Zie commit 3d828a61.
#
# Wat dit doet:
# Het commando komt in zijn eigen cgroup met `MemoryMax`. Als het door die
# grens heen groeit krijgt alleen die cgroup een OOM-signaal: het commando
# zelf sterft, de rest van de machine leeft. Dat is precies het gedrag dat
# je wilt — de build faalt, de site blijft staan.
#
# Met `--max-old-space-size` lukt dat niet. Die limiet zit op de V8-heap en
# Turbopack-geheugen is native Rust-geheugen; met een 2GB cap piekte de RSS
# alsnog op 8GB. Zie het commentaar in de Dockerfile.
#
# Backends:
#
# systemd (cgroup MemoryMax)
# Meet RSS over de héle procesboom. Dit is de echte garantie en wordt
# overal gebruikt waar systemd beschikbaar is (de host waar deze
# CMS draait). De RSS-plafonds in package.json zijn hierop gekozen:
# `next build` piekte op 6,5GB, dus 10GB laat ruimte over terwijl er
# 6GB basislast naast blijft passen binnen de 23,5GB van deze machine.
#
# ulimit -v (per proces, virtuele adresruimte)
# Alleen als expliciet gevraagd. Meet virtuele adresruimte, NIET RSS, en
# kan de boom helemaal niet begrenzen: elke worker krijgt z'n eigen
# limiet. Op moderne V8 is het bovendien een vergiftigde gift: `-v 10g`
# laat V8 de heaplimiet terugbrengen naar 2,25GB (webpack sterft met
# std::bad_alloc), en `-v 20g` laat de v8-wasm-memory-toewijzing falen
# tijdens `next build`. Zet CMS_MEMORY_CAP_VIRTUAL ruim boven de fysieke
# RAM als je het echt wilt gebruiken.
#
# Geen van beide -> weigeren. Stil onbegrensd doorlopen zou precies de
# valse geruststelling zijn waar 3d828a61 voor waarschuwt. Omgevingen
# zonder systemd (de Docker-build, de GitLab-runner) kiezen daarom
# expliciet voor CMS_MEMORY_CAP_BACKEND=none — met een waarschuwing,
# en met als rechtvaardiging dat die builds al begrensd zijn door
# `next build --webpack` + `--max-old-space-size` en in hun eigen
# geïsoleerde container draaien, niet op de host.
#
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
set -Eeuo pipefail
usage() {
echo "gebruik: $0 <plafond, bv. 10g> <command...>" >&2
exit 64
}
[ "$#" -ge 2 ] || usage
cap="$1"
shift
# Zet 10g / 512m / 2G / 1234567 om in bytes. Alleen bytes gaan naar
# systemd: MemoryMax accepteert `10G` maar weigert `10g`, en die
# hoofdletterval is te makkelijk om per ongeluk te treffen.
to_bytes() {
local value="$1" number suffix
if [[ "$value" =~ ^([0-9]+)([kKmMgGtT]?)$ ]]; then
number="${BASH_REMATCH[1]}"
suffix="${BASH_REMATCH[2]}"
else
echo "onbekend plafond-formaat: $value" >&2
return 1
fi
case "$suffix" in
k | K) echo $((number * 1024)) ;;
m | M) echo $((number * 1024 * 1024)) ;;
g | G) echo $((number * 1024 * 1024 * 1024)) ;;
t | T) echo $((number * 1024 * 1024 * 1024 * 1024)) ;;
*) echo "$number" ;;
esac
}
bytes="$(to_bytes "$cap")" || exit 64
kilobytes=$((bytes / 1024))
# De virtuele waarde voor ulimit -v. Bewust los van `cap`: zie de toelichting
# hierboven, 10g -v breekt de webpack-build.
virtual_bytes="$(to_bytes "${CMS_MEMORY_CAP_VIRTUAL:-40g}")" || exit 64
virtual_kb=$((virtual_bytes / 1024))
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
systemd_cmd=()
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
# zonder rechten faalt met "Access denied", en dat moet dan een nette
# terugval naar ulimit worden in plaats van een kapotte build.
probe_systemd() {
command -v systemd-run >/dev/null 2>&1 || return 1
[ -d /run/systemd/system ] || return 1
if systemd-run --scope --quiet true 2>/dev/null; then
systemd_cmd=(systemd-run --scope --quiet)
return 0
fi
if systemd-run --user --scope --quiet true 2>/dev/null; then
systemd_cmd=(systemd-run --user --scope --quiet)
return 0
fi
return 1
}
run_systemd() {
echo "[mem-cap] systemd cgroup MemoryMax=$((bytes / 1024 / 1024 / 1024))GB: $*" >&2
exec "${systemd_cmd[@]}" -p "MemoryMax=$bytes" "$@"
}
run_ulimit() {
echo "[mem-cap] ulimit -v ${virtual_kb}KB per proces (geen systemd; RSS-plafond ${cap} niet meetbaar zonder cgroup): $*" >&2
if [ "$virtual_bytes" -lt $((16 * 1024 * 1024 * 1024)) ]; then
echo "[mem-cap] let op: -v onder 16g verlaagt V8's heaplimiet en breekt de build; verhoog CMS_MEMORY_CAP_VIRTUAL" >&2
fi
ulimit -v "$virtual_kb" || {
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
return 1
}
exec "$@"
}
run_refuse() {
echo "[mem-cap] geen systemd hier; weiger onbegrensd te draaien." >&2
echo "[mem-cap] zet CMS_MEMORY_CAP_BACKEND=none om dit bewust te accepteren, of =ulimit voor een per-proces vangnet." >&2
return 1
}
run_opted_out() {
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
exec "$@"
}
case "$backend" in
systemd)
probe_systemd || {
echo "[mem-cap] CMS_MEMORY_CAP_BACKEND=systemd maar systemd-run reageert niet" >&2
exit 70
}
run_systemd "$@"
;;
ulimit)
run_ulimit "$@"
;;
none | off)
run_opted_out "$@"
;;
auto)
if probe_systemd; then
run_systemd "$@"
else
run_refuse "$@"
fi
;;
*)
echo "[mem-cap] onbekende backend: $backend" >&2
exit 64
;;
esac
+30 -34
View File
@@ -4,11 +4,32 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/**
* Store an uploaded media file under MEDIA_ROOT.
*
* The extension always comes from the *detected* format (magic bytes + a full
* sharp decode), never from `file.name` or the browser-supplied MIME type:
* trusting either lets arbitrary bytes land on disk with an attacker-chosen name
* that the media route would then serve.
*/
async function storeUploadedMedia(
file: File,
): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
const validated = await validateSiteImageUpload(file);
if (!validated.success) return { ok: false, error: validated.error };
const baseDir = MEDIA_ROOT;
await mkdir(baseDir, { recursive: true });
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${validated.extension}`;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep))
return { ok: false, error: "Invalid path" };
await writeFile(filePath, validated.bytes);
return { ok: true, name };
}
export async function uploadMedia(
formData: FormData,
@@ -16,25 +37,9 @@ export async function uploadMedia(
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { ok: false, error: "File too large (max 5MB)" };
if (!ALLOWED.includes(file.type))
return {
ok: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
};
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
const stored = await storeUploadedMedia(file);
if (!stored.ok) return { ok: false, error: stored.error };
revalidatePath("/api/media");
revalidatePath("/admin/media");
@@ -45,6 +50,8 @@ export async function deleteMedia(name: string): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const { unlink } = await import("node:fs/promises");
const baseDir = MEDIA_ROOT;
// A name that is not a bare file name never reaches the unlink.
if (name.includes("/") || name.includes("\\") || name.includes("..")) return;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return;
try {
@@ -62,22 +69,11 @@ export async function uploadMediaAndReturn(
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return "";
if (file.size > MAX_SIZE) return "";
if (!ALLOWED.includes(file.type)) return "";
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return "";
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
const stored = await storeUploadedMedia(file);
if (!stored.ok) return "";
revalidatePath("/api/media");
revalidatePath("/admin/media");
return `/api/media/${name}`;
return `/api/media/${stored.name}`;
}
+27 -7
View File
@@ -14,10 +14,19 @@ import {
import { PERMS } from "@/lib/permissions";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import {
isSecretSettingKey,
SECRET_PLACEHOLDER,
} from "@/lib/services/setting-secrets";
import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
// Raw keys only the CMS core is allowed to own. Writing an arbitrary key from
// the generic "advanced key/value" form previously meant a staff member could
// overwrite `turnstile_secret`, `force_staff_2fa` or `min_staff_rank`.
const RAW_SETTING_KEY_RE = /^[a-z0-9][a-z0-9_.-]{0,127}$/;
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
@@ -71,11 +80,12 @@ export async function updateSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return;
const raw = String(formData.get("value") ?? "").normalize("NFC");
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
// Blank on a secret means "keep what is stored", so the UI can render a
// placeholder without the risk of wiping the credential.
if (isSecretSettingKey(key) && raw === SECRET_PLACEHOLDER) return;
const value = isSecretSettingKey(key) ? raw : normalizeSettingValue(key, raw);
await db
.insert(WebsiteSetting)
.values({ key, value })
@@ -90,7 +100,7 @@ export async function createSetting(formData: FormData): Promise<void> {
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
.slice(0, 128);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
@@ -99,7 +109,17 @@ export async function createSetting(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, 255);
if (!key) return;
// Managed keys go through `saveManagedSettings`; anything else must be a
// clearly namespaced custom key, and lockout/security settings are never
// writable through the free-form form.
if (!key || !RAW_SETTING_KEY_RE.test(key)) return;
if (
key === "force_staff_2fa" ||
key === "min_staff_rank" ||
key === "maintenance_enabled"
) {
return;
}
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
+65
View File
@@ -15,6 +15,9 @@ const core = vi.hoisted(() => ({
.trim(),
password: String(password ?? "").normalize("NFC"),
}),
isLoginLocked: vi.fn(async () => false),
recordLoginFailure: vi.fn(async () => false),
clearLoginLockout: vi.fn(async () => undefined),
}));
vi.mock("@/env", () => ({ env: {} }));
@@ -27,8 +30,14 @@ vi.mock("@/lib/services/captcha", () => ({
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { getBool: vi.fn() },
}));
vi.mock("@/lib/auth/login-lockout", () => ({
isLoginLocked: core.isLoginLocked,
recordLoginFailure: core.recordLoginFailure,
clearLoginLockout: core.clearLoginLockout,
}));
const user = (overrides = {}) => ({
id: 42,
password: "hash",
twoFactorConfirmedAt: null,
mail: null,
@@ -45,6 +54,9 @@ beforeEach(() => {
core.verifyLoginPassword.mockResolvedValue({ valid: true });
core.isEmailUnverified.mockResolvedValue(false);
core.runDummyHashCheck.mockResolvedValue(undefined);
core.isLoginLocked.mockResolvedValue(false);
core.recordLoginFailure.mockResolvedValue(false);
core.clearLoginLockout.mockResolvedValue(undefined);
});
describe("precheckLogin", () => {
@@ -85,4 +97,57 @@ describe("precheckLogin", () => {
core.isEmailUnverified.mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified");
});
it("returns locked for an account that is already locked out", async () => {
core.getLoginUser.mockResolvedValue(user());
core.isLoginLocked.mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("locked");
// The password is never verified while locked, so a correct password
// cannot walk a locked account back in.
expect(core.verifyLoginPassword).not.toHaveBeenCalled();
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
it("checks the lockout before verifying the password", async () => {
core.getLoginUser.mockResolvedValue(user());
const order: string[] = [];
core.getLoginUser.mockImplementation(async () => {
order.push("lookup");
return user();
});
core.isLoginLocked.mockImplementation(async () => {
order.push("lock");
return false;
});
core.verifyLoginPassword.mockImplementation(async () => {
order.push("verify");
return { valid: true };
});
expect(await precheckLogin("user", "pass")).toBe("ok");
expect(order).toEqual(["lookup", "lock", "verify"]);
});
it("records a failure and skips the clear when the password is wrong", async () => {
core.getLoginUser.mockResolvedValue(user());
core.verifyLoginPassword.mockResolvedValue({ valid: false });
core.recordLoginFailure.mockResolvedValue(false);
expect(await precheckLogin("user", "pass")).toBe("invalid");
expect(core.recordLoginFailure).toHaveBeenCalledWith(42);
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
it("clears the lockout after a successful authentication", async () => {
core.getLoginUser.mockResolvedValue(user());
expect(await precheckLogin("user", "pass")).toBe("ok");
expect(core.clearLoginLockout).toHaveBeenCalledWith(42);
expect(core.recordLoginFailure).not.toHaveBeenCalled();
});
it("does not lock or clear a bucket for an unknown account", async () => {
core.getLoginUser.mockResolvedValue(null);
expect(await precheckLogin("nonexistent", "pass")).toBe("invalid");
expect(core.isLoginLocked).not.toHaveBeenCalled();
expect(core.recordLoginFailure).not.toHaveBeenCalled();
expect(core.clearLoginLockout).not.toHaveBeenCalled();
});
});
+18 -2
View File
@@ -7,6 +7,11 @@ import {
runDummyHashCheck,
verifyLoginPassword,
} from "@/lib/auth/login-core";
import {
clearLoginLockout,
isLoginLocked,
recordLoginFailure,
} from "@/lib/auth/login-lockout";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -15,7 +20,8 @@ export type PrecheckResult =
| "invalid"
| "twofactor"
| "unverified"
| "captcha";
| "captcha"
| "locked";
/**
* Validates username+password WITHOUT creating a session, and reports whether a
@@ -38,6 +44,9 @@ export async function precheckLogin(
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
// A lockout must be checked BEFORE the password is verified: the success
// path clears the counter, which would otherwise let an already-locked
// account straight back in with the correct credentials.
const user = await getLoginUser(u);
if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check.
@@ -45,8 +54,15 @@ export async function precheckLogin(
return "invalid";
}
if (await isLoginLocked(user.id)) return "locked";
const res = await verifyLoginPassword(user, p);
if (!res.valid) return "invalid";
if (!res.valid) {
await recordLoginFailure(user.id);
return "invalid";
}
await clearLoginLockout(user.id);
if (await isEmailUnverified(user)) {
return "unverified";
+9 -2
View File
@@ -41,7 +41,11 @@ const {
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "users.edit" },
// Staff (rank 7) may act on anyone below the hotel's top rank.
getHighestRank: vi.fn(() => Promise.resolve(10)),
}));
vi.mock("@/lib/db", () => ({
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
@@ -109,7 +113,10 @@ beforeEach(() => {
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([]);
// Rank rows for the per-id rank guard: every target sits below staff rank 7.
selectWhereResolved.mockResolvedValue([{ rank: 1 }]);
// Max slot of existing badges (consumed by the badge loop, not the guard).
selectWhereResolved.mockResolvedValueOnce([{ rank: 1 }]);
});
describe("bulkUnban", () => {
+101 -32
View File
@@ -1,6 +1,7 @@
"use server";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { requirePermission } from "@/lib/admin/guard";
import {
Ban,
@@ -11,18 +12,69 @@ import {
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { getHighestRank, PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
/**
* Bulk actions are plain server actions whose arguments come from the client,
* so every one of them validates the payload and the target ranks first. The
* helpers below are the whole "is this allowed" contract.
*/
const MAX_BULK_USERS = 200;
function parseUserIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const ids = raw
.map((v) => (typeof v === "number" ? v : Number(v)))
.filter((v) => Number.isInteger(v) && v > 0);
return [...new Set(ids)].slice(0, MAX_BULK_USERS);
}
function toPositiveInt(raw: unknown): number | null {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
function parseAmount(raw: unknown, max = 1_000_000): number | null {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 && n <= max ? n : null;
}
function parseDuration(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isInteger(n) && n > 0 ? Math.min(n, 60 * 60 * 24 * 365) : 0;
}
async function guardBulkTargets(
staff: { id: number; rank: number },
userIds: number[],
): Promise<void> {
const highestRank = await getHighestRank();
const superAdmin = isDynamicSuperAdmin(staff.rank, highestRank);
if (superAdmin || userIds.length === 0) return;
const rows = await db
.select({ rank: User.rank })
.from(User)
.where(inArray(User.id, userIds));
const blocked = rows.filter((r) => r.rank >= staff.rank);
if (blocked.length > 0) {
throw new Error(
"Cannot act on a user at or above your rank — those ids were skipped",
);
}
}
export async function bulkUnban({
userIds,
}: {
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const ids = parseUserIds(userIds);
await guardBulkTargets(staff, ids);
const result = await db.delete(Ban).where(inArray(Ban.userId, ids));
const unbanned = Number(result[0]?.affectedRows ?? 0);
await logStaffActivity({
staffId: staff.id,
@@ -30,10 +82,7 @@ export async function bulkUnban({
description: `Unbanned ${unbanned} user(s)`,
targetType: "user",
});
return {
ok: true as const,
data: { unbanned, total: userIds.length },
};
return { ok: true as const, data: { unbanned, total: ids.length } };
}
export async function bulkBan({
@@ -46,10 +95,14 @@ export async function bulkBan({
duration: number;
}): Promise<ActionResult<{ banned: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const seconds = parseDuration(duration);
const reasonText = typeof reason === "string" ? reason.slice(0, 255) : "";
await guardBulkTargets(staff, ids);
const now = Math.floor(Date.now() / 1000);
let banned = 0;
for (const userId of userIds) {
for (const userId of ids) {
try {
await db.insert(Ban).values({
userId,
@@ -57,8 +110,8 @@ export async function bulkBan({
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
banExpire: seconds > 0 ? now + seconds : 0,
banReason: reasonText,
type: "account",
});
banned++;
@@ -92,33 +145,37 @@ export async function bulkGiveCurrency({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const value = parseAmount(amount);
if (!value) throw new Error("Invalid amount");
await guardBulkTargets(staff, ids);
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
for (const userId of ids) {
try {
if (type === "credits") {
await db
.update(User)
.set({ credits: sql`${User.credits} + ${amount}` })
.set({ credits: sql`${User.credits} + ${value}` })
.where(eq(User.id, userId));
await rcon.giveCredits(userId, amount);
await rcon.giveCredits(userId, value);
} else if (type === "pixels") {
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount })
.values({ userId, type: 0, amount: value })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
});
await rcon.giveDuckets(userId, amount);
await rcon.giveDuckets(userId, value);
} else if (type === "points") {
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount })
.values({ userId, type: 101, amount: value })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
set: { amount: sql`${UsersCurrency.amount} + ${value}` },
});
await rcon.givePointsGotw(userId, amount);
await rcon.givePointsGotw(userId, value);
}
given++;
} catch {
@@ -129,7 +186,7 @@ export async function bulkGiveCurrency({
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_currency",
description: `Gave ${amount} ${type} to ${given} user(s)`,
description: `Gave ${value} ${type} to ${given} user(s)`,
targetType: "user",
});
return {
@@ -152,19 +209,21 @@ export async function bulkGiveBadge({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
const code =
typeof badgeCode === "string" ? badgeCode.trim().slice(0, 64) : "";
if (!code) throw new Error("Invalid badge code");
await guardBulkTargets(staff, ids);
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
for (const userId of ids) {
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) {
@@ -173,8 +232,10 @@ export async function bulkGiveBadge({
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
await db
.insert(UsersBadges)
.values({ userId, slotId, badgeCode: code });
await rcon.giveBadge(userId, code);
}
given++;
} catch {
@@ -211,12 +272,17 @@ export async function bulkAdjustCurrency({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const ids = parseUserIds(userIds);
if (!Number.isFinite(amount) || amount === 0) {
return { ok: false as const, error: "Amount must be a non-zero number" };
}
if (Math.abs(Math.trunc(amount)) > 1_000_000) {
return { ok: false as const, error: "Amount is too large" };
}
await guardBulkTargets(staff, ids);
if (amount > 0) {
const given = await bulkGiveCurrency({ userIds, amount, type });
const given = await bulkGiveCurrency({ userIds: ids, amount, type });
if (!given.ok) return given;
if (!given.data) {
return { ok: false as const, error: "Currency adjustment failed" };
@@ -235,7 +301,7 @@ export async function bulkAdjustCurrency({
let adjusted = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
for (const userId of ids) {
try {
if (type === "credits") {
const [user] = await db
@@ -299,8 +365,11 @@ export async function setTradeLock({
untilUnix: number;
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const until = Math.max(0, Math.trunc(untilUnix));
const id = toPositiveInt(userId);
if (!id) return { ok: false as const, error: "Invalid user" };
const until = Math.max(0, Math.min(Math.trunc(untilUnix), 2_000_000_000));
const locked = until > 0;
await guardBulkTargets(staff, [id]);
const [user] = await db
.select({
@@ -309,7 +378,7 @@ export async function setTradeLock({
online: User.online,
})
.from(User)
.where(eq(User.id, userId))
.where(eq(User.id, id))
.limit(1);
if (!user) {
return { ok: false as const, error: "User not found" };
@@ -331,7 +400,7 @@ export async function setTradeLock({
.where(eq(Sanctions.id, existing.id));
} else {
await tx.insert(Sanctions).values({
habboId: userId,
habboId: id,
tradeLockedUntil: until,
reason: locked ? "Trade lock (CMS)" : "",
});
@@ -369,5 +438,5 @@ export async function setTradeLock({
targetId: userId,
});
return { ok: true as const, data: { userId, untilUnix: until } };
return { ok: true as const, data: { userId: id, untilUnix: until } };
}
+29
View File
@@ -52,6 +52,13 @@ const mockSetMotto = vi.hoisted(() => vi.fn());
const mockSetRank = vi.hoisted(() => vi.fn());
const mockExecuteCommand = vi.hoisted(() => vi.fn());
const mockSendGift = vi.hoisted(() => vi.fn());
// The audit service is exercised separately; here it only has to be harmless.
// Mocked explicitly because the fake db has no `insert`, which used to leak an
// unhandled rejection out of the fire-and-forget audit call.
vi.mock("@/lib/services/audit", () => ({
logAudit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: {
send: mockSend,
@@ -455,3 +462,25 @@ describe("access control", () => {
});
});
});
describe("auditing", () => {
it("logs an entry for a currency grant", async () => {
const { logAudit } = await import("@/lib/services/audit");
await giveCredits({ userId: 1, credits: 100 });
expect(logAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: expect.any(String) }),
);
});
it("completes the command even when auditing rejects", async () => {
const { logAudit } = await import("@/lib/services/audit");
vi.mocked(logAudit).mockRejectedValueOnce(new Error("audit table missing"));
await expect(giveCredits({ userId: 1, credits: 100 })).resolves.toEqual({
ok: true,
data: {},
});
// Let the fire-and-forget promise settle; an unhandled rejection here is
// exactly the failure this guards against.
await new Promise((r) => setTimeout(r, 0));
});
});
+44
View File
@@ -7,12 +7,35 @@ import { db, queryRows, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
/** Currency amounts are capped: unbounded values break the hotel economy. */
const MAX_CURRENCY = 1_000_000;
/** Every mutation here gets an audit entry; rank changes and RCON most of all. */
function auditAction(
userId: number,
action: string,
targetId: number,
after: Record<string, unknown>,
): void {
// logAudit is async, so a surrounding try/catch cannot see its rejection —
// it would surface as an unhandled rejection and, in production, take the
// request down over a failing audit insert. Swallow it on the promise
// instead, which is what "auditing must never fail the command it
// describes" actually requires.
void Promise.resolve()
.then(() => logAudit({ userId, action, target: "User", targetId, after }))
.catch(() => {
/* auditing must never fail the command it describes */
});
}
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
}
@@ -120,9 +143,16 @@ const giveCreditsSchema = z.object({
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
if (ctx.data.credits > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
auditAction(Number(ctx.session.user.id), "give_credits", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.credits,
});
revalidatePath(PATH);
return actionOk();
},
@@ -137,9 +167,16 @@ const giveAmountSchema = z.object({
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
auditAction(Number(ctx.session.user.id), "give_duckets", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.amount,
});
revalidatePath(PATH);
return actionOk();
},
@@ -149,9 +186,16 @@ export const giveDuckets = adminAction(
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
if (ctx.data.amount > MAX_CURRENCY) {
throw new ActionError(`Amount is too large (max ${MAX_CURRENCY})`);
}
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
auditAction(Number(ctx.session.user.id), "give_diamonds", ctx.data.userId, {
userId: ctx.data.userId,
amount: ctx.data.amount,
});
revalidatePath(PATH);
return actionOk();
},
+17
View File
@@ -10,8 +10,13 @@ const state = vi.hoisted(() => ({
deletes: [] as unknown[],
affectedDelete: 1,
emptyDeleteResult: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
// The real moderation module loads the word filter through the (mocked) db,
// which would silently change the rows the offline-message assertions read.
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
@@ -315,6 +320,18 @@ describe("sendOfflineMessage", () => {
expect(redirected()).toBe("/messages?send_error=invalid");
});
it("rejects content blocked by the word filter before storing it", async () => {
state.friendships = [{ id: 1 }];
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
await redirects(() =>
sendOfflineMessage(fakeForm({ friendId: "2", message: "rude words" })),
);
expect(state.isAllowed).toHaveBeenCalledWith("rude words");
expect(state.inserts).toHaveLength(0);
expect(redirected()).toBe("/messages?send_error=invalid");
state.isAllowed.mockResolvedValue({ ok: true });
});
it("stores an offline message for a friend", async () => {
state.friendships = [{ id: 1 }];
await redirects(() =>
+3
View File
@@ -12,6 +12,7 @@ import {
User,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
type FriendOutcome =
| "accepted"
@@ -376,6 +377,8 @@ export async function sendOfflineMessage(formData: FormData): Promise<void> {
.limit(1);
if (!recipient) {
outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else {
await db.insert(MessengerOffline).values({
userId: friendId,
+18 -5
View File
@@ -1,14 +1,14 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const { selectLimit, insertOnDup, mockSendMail, mockRedirect } = vi.hoisted(
() => ({
const { selectLimit, selectWhere, insertOnDup, mockSendMail, mockRedirect } =
vi.hoisted(() => ({
selectLimit: vi.fn(),
insertOnDup: vi.fn().mockResolvedValue({}),
selectWhere: vi.fn(() => Promise.resolve([] as Array<{ id: number }>)),
mockSendMail: vi.fn(),
mockRedirect: vi.fn(),
}),
);
}));
vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => {
@@ -24,6 +24,17 @@ vi.mock("@/lib/db", () => {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// Matches the deterministic `.orderBy(asc(User.id))` list
// reads used to resolve duplicate addresses.
orderBy: vi.fn(() => ({
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(
resolve: (v: unknown) => void,
reject: (e: unknown) => void,
) {
return Promise.resolve(selectWhere()).then(resolve, reject);
},
})),
})),
})),
})),
@@ -70,13 +81,14 @@ beforeEach(() => {
describe("requestReset", () => {
it("sends a reset email when the user exists", async () => {
selectLimit.mockResolvedValue([{ id: 1 }]);
selectWhere.mockResolvedValue([{ id: 1 }]);
const fd = new FormData();
fd.set("email", "[email protected]");
await expect(requestReset(fd)).rejects.toThrow("redirect");
expect(selectLimit).toHaveBeenCalled();
expect(selectWhere).toHaveBeenCalled();
expect(insertOnDup).toHaveBeenCalled();
expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]",
@@ -87,6 +99,7 @@ describe("requestReset", () => {
it("does not send email when user is not found", async () => {
selectLimit.mockResolvedValue([]);
selectWhere.mockResolvedValue([]);
const fd = new FormData();
fd.set("email", "[email protected]");
+50 -20
View File
@@ -1,11 +1,14 @@
"use server";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { eq } from "drizzle-orm";
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { logServerError } from "@/lib/server-log";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -17,6 +20,17 @@ function sha256(s: string): string {
return createHash("sha256").update(s).digest("hex");
}
/**
* Back to the reset form with a *code*, never with the human-readable message:
* a raw `?error=` value would be rendered on our own domain, which is a
* perfect phishing skeleton. The page maps each code to a translation.
*/
function errorRedirect(email: string, token: string, code: string): never {
return redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${code}`,
);
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
@@ -40,12 +54,23 @@ export async function requestReset(formData: FormData): Promise<void> {
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const [user] = await db
const matches = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
.orderBy(asc(User.id));
if (matches.length > 1) {
logger.warn("Password reset address is not unique", {
email,
accountCount: matches.length,
using: matches[0]?.id,
});
}
const user = matches[0];
if (user) {
// Duplicate addresses exist on legacy databases; resetting the
// *oldest* account keeps the choice deterministic instead of
// "whatever row the engine returns first".
const token = randomBytes(32).toString("hex");
const hashed = sha256(token);
const createdAt = new Date();
@@ -80,13 +105,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
);
redirect(errorRedirect(email, token, "ratelimit"));
}
let error: string | null = null;
if (password.length < 6) error = "Password must be at least 6 characters";
let error: "password" | "invalid" | "failed" | null = null;
if (password.length < 12) error = "password";
if (!error) {
try {
@@ -107,20 +130,29 @@ export async function resetPassword(formData: FormData): Promise<void> {
row != null && a.length === b.length && timingSafeEqual(a, b);
if (!row || !fresh || !match) {
error = "This reset link is invalid or has expired";
error = "invalid";
} else {
const [user] = await db
const matches = await db
.select({ id: User.id })
.from(User)
.where(eq(User.mail, email))
.limit(1);
.orderBy(asc(User.id));
const user = matches[0];
if (!user) {
error = "Account not found";
error = "invalid";
} else {
await db
.update(User)
.set({ password: await hashPassword(password) })
.where(eq(User.id, user.id));
const newHash = await hashPassword(password);
// A password change has to end every existing session: the
// popular reason for resetting is a compromised account, and a
// stolen cookie/API token must not outlive the reset.
await Promise.all([
db
.update(User)
.set({ password: newHash })
.where(eq(User.id, user.id)),
revokeUserCredentials(user.id),
]);
await invalidateLoginCache(email);
await db
.delete(PasswordReset)
.where(eq(PasswordReset.email, email))
@@ -132,14 +164,12 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
}
} catch {
error = "Could not reset the password — try again";
error = "failed";
}
}
if (error) {
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
);
redirect(errorRedirect(email, token, error));
}
redirect("/login?reset=1");
}
+7 -3
View File
@@ -175,8 +175,10 @@ export const setCmsPermissions = adminAction(
);
/**
* Re-apply the same grant repair as migration 0018:
* - ranks with admin.dashboard get all admin.*
* Re-apply the grant repair from migration 0018/0034:
* - ranks with admin.dashboard get all admin.*.view (read-only: the sidebar
* needs to open, nothing more — a blanket `admin.%` grant here is what
* promoted rank 6 to full admin)
* - ranks >= 6 get admin.*.view + dashboard
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
*/
@@ -187,7 +189,9 @@ export const repairAdminNavAclGrants = adminAction(
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`acl_roles\` ar
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
-- View slugs only: widening this to all admin.* turned "can open the
-- panel" into "is a full admin" for every mid rank (see 0034).
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view'
WHERE EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
+25 -2
View File
@@ -184,6 +184,7 @@ describe("register", () => {
expect(result).toEqual({
error: "Username must be at least 3 characters",
ok: false,
code: "usernameMinLength",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -191,6 +192,7 @@ describe("register", () => {
it("rejects usernames containing characters outside the allowed set", async () => {
const result = await register(PREV, buildForm({ username: "bad name!" }));
expect(result.error).toContain("letters, numbers, underscore and hyphen");
expect(result.code).toBe("usernamePattern");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -199,6 +201,7 @@ describe("register", () => {
expect(result).toEqual({
error: "Enter a valid email address",
ok: false,
code: "emailValid",
});
});
@@ -207,6 +210,7 @@ describe("register", () => {
expect(result).toEqual({
error: "Password must be at least 12 characters",
ok: false,
code: "passwordMinLength",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -220,6 +224,7 @@ describe("register", () => {
}),
);
expect(result.error).toContain("uppercase");
expect(result.code).toBe("passwordUpper");
});
it("rejects passwords without a digit", async () => {
@@ -231,6 +236,7 @@ describe("register", () => {
}),
);
expect(result.error).toContain("digit");
expect(result.code).toBe("passwordDigit");
});
it("rejects passwords without a special character", async () => {
@@ -242,6 +248,7 @@ describe("register", () => {
}),
);
expect(result.error).toContain("special");
expect(result.code).toBe("passwordSpecial");
});
it("rejects mismatched password confirmations", async () => {
@@ -249,13 +256,18 @@ describe("register", () => {
PREV,
buildForm({ password_confirmation: "Different1" }),
);
expect(result).toEqual({ error: "Passwords do not match", ok: false });
expect(result).toEqual({
error: "Passwords do not match",
ok: false,
code: "passwordsMatch",
});
});
it("throttles sign-ups per IP", async () => {
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
const result = await runValidRegistration();
expect(result.error).toContain("Too many sign-up attempts");
expect(result.code).toBe("rateLimited");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -273,6 +285,7 @@ describe("register", () => {
expect(result).toEqual({
error: "Captcha verification failed. Please try again.",
ok: false,
code: "captchaFailed",
});
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
expect(state.insert).not.toHaveBeenCalled();
@@ -290,6 +303,7 @@ describe("register", () => {
expect(result).toEqual({
error: "You must accept the terms and conditions to register.",
ok: false,
code: "termsRequired",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -298,7 +312,11 @@ describe("register", () => {
state.checkVpn.mockResolvedValue({ blocked: true });
state.siteGet.mockResolvedValueOnce("Custom VPN message");
const result = await runValidRegistration();
expect(result).toEqual({ error: "Custom VPN message", ok: false });
expect(result).toEqual({
error: "Custom VPN message",
ok: false,
code: "vpnBlocked",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -317,6 +335,7 @@ describe("register", () => {
state.countTotal = 2;
const result = await runValidRegistration();
expect(result.error).toContain("maximum number of accounts");
expect(result.code).toBe("maxAccountsPerIp");
expect(state.insert).not.toHaveBeenCalled();
});
@@ -340,6 +359,7 @@ describe("register", () => {
expect(result).toEqual({
error: "That username is already taken",
ok: false,
code: "usernameTaken",
});
expect(state.insert).not.toHaveBeenCalled();
});
@@ -350,6 +370,7 @@ describe("register", () => {
expect(result).toEqual({
error: "Registration is temporarily unavailable",
ok: false,
code: "unavailable",
});
expect(state.logger.warn).toHaveBeenCalledWith(
"Username uniqueness check failed during registration",
@@ -365,6 +386,7 @@ describe("register", () => {
expect(result).toEqual({
error: "That username is already taken",
ok: false,
code: "usernameTaken",
});
expect(state.logger.error).not.toHaveBeenCalled();
});
@@ -373,6 +395,7 @@ describe("register", () => {
state.insert.mockRejectedValueOnce(new Error("db exploded"));
const result = await runValidRegistration();
expect(result.error).toContain("Could not create the account");
expect(result.code).toBe("createFailed");
expect(state.logger.error).toHaveBeenCalledWith(
"Account creation failed",
expect.objectContaining({ message: "db exploded" }),
+72 -7
View File
@@ -121,19 +121,72 @@ const registerSchema = z
path: ["passwordConfirmation"],
});
/**
* Stable, locale-independent reason for a failed sign-up. The client maps these
* onto `pages.register.<code>` so the form speaks the visitor's language; the
* English `error` string stays as a fallback and for API/log consumers.
*/
export type RegisterErrorCode =
| "usernameMinLength"
| "usernameMaxLength"
| "usernamePattern"
| "usernameReserved"
| "usernameTaken"
| "emailValid"
| "emailDisposable"
| "passwordMinLength"
| "passwordMaxLength"
| "passwordUpper"
| "passwordLower"
| "passwordDigit"
| "passwordSpecial"
| "passwordsMatch"
| "termsRequired"
| "captchaFailed"
| "rateLimited"
| "vpnBlocked"
| "maxAccountsPerIp"
| "unavailable"
| "createFailed"
| "invalidInput";
/** Maps the schema's English messages onto locale-independent codes. */
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
"Username must be at least 3 characters": "usernameMinLength",
"Username must be at most 25 characters": "usernameMaxLength",
"Username may only contain letters, numbers, underscore and hyphen":
"usernamePattern",
"This username is reserved": "usernameReserved",
"Enter a valid email address": "emailValid",
"Temporary email domains are not allowed": "emailDisposable",
"Password must be at least 12 characters": "passwordMinLength",
"Password is too long": "passwordMaxLength",
"Password must contain at least one uppercase letter": "passwordUpper",
"Password must contain at least one lowercase letter": "passwordLower",
"Password must contain at least one digit": "passwordDigit",
"Password must contain at least one special character": "passwordSpecial",
"Passwords do not match": "passwordsMatch",
};
// A valid starter Habbo figure so the avatar renders in-client immediately.
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export interface RegisterState {
error: string | null;
ok: boolean;
/** Locale-independent reason, present on every failure. */
code?: RegisterErrorCode;
}
export async function register(
_prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const fail = (error: string): RegisterState => ({ error, ok: false });
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
error,
ok: false,
code,
});
const raw = {
username: String(formData.get("username") ?? "")
.normalize("NFC")
@@ -155,7 +208,8 @@ export async function register(
const parsed = registerSchema.safeParse(raw);
if (!parsed.success) {
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
const message = parsed.error.issues[0]?.message ?? "Invalid input";
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
}
const { username, mail, password, look } = parsed.data;
@@ -166,6 +220,7 @@ export async function register(
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
return fail(
"Too many sign-up attempts. Please wait a few minutes and try again.",
"rateLimited",
);
}
@@ -174,18 +229,25 @@ export async function register(
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip)))
return fail("Captcha verification failed. Please try again.");
return fail(
"Captcha verification failed. Please try again.",
"captchaFailed",
);
}
// Terms acceptance check.
if (!raw.termsAccepted)
return fail("You must accept the terms and conditions to register.");
return fail(
"You must accept the terms and conditions to register.",
"termsRequired",
);
// VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) {
return fail(
(await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed.",
"vpnBlocked",
);
}
@@ -200,6 +262,7 @@ export async function register(
if (Number(row?.total ?? 0) >= max)
return fail(
"You have reached the maximum number of accounts for your connection.",
"maxAccountsPerIp",
);
}
@@ -210,10 +273,11 @@ export async function register(
.from(User)
.where(eq(User.username, username))
.limit(1);
if (existing) return fail("That username is already taken");
if (existing)
return fail("That username is already taken", "usernameTaken");
} catch {
logger.warn("Username uniqueness check failed during registration");
return fail("Registration is temporarily unavailable");
return fail("Registration is temporarily unavailable", "unavailable");
}
const now = Math.floor(Date.now() / 1000);
@@ -233,7 +297,7 @@ export async function register(
} catch (err) {
const code = (err as { cause?: { code?: string } }).cause?.code;
if (code === "ER_DUP_ENTRY") {
return fail("That username is already taken");
return fail("That username is already taken", "usernameTaken");
}
logger.error("Account creation failed", {
code,
@@ -241,6 +305,7 @@ export async function register(
});
return fail(
"Could not create the account. Please try again or contact staff.",
"createFailed",
);
}
+5 -2
View File
@@ -76,14 +76,17 @@ describe("rooms actions", () => {
});
state.del.mockResolvedValue([{ affectedRows: 1 }]);
state.update.mockResolvedValue([{ affectedRows: 1 }]);
// The item/room ownership lookups must find their row.
state.roomRows = [{ name: "Lobby" }, { id: 4 }];
});
it("requires the ROOMS_EDIT permission for updateRoomItem", async () => {
await updateRoomItem({ roomId: 9, itemId: 4, custom: "x" });
// `custom` is not an allow-listed column, so it must never reach `.set()`.
await updateRoomItem({ roomId: 9, itemId: 4, rot: 4, custom: "x" });
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
expect(state.update).toHaveBeenCalledWith(
Items,
{ custom: "x" },
{ rot: 4 },
expect.anything(),
);
expect(state.logStaffActivity).toHaveBeenCalledWith(
+63 -20
View File
@@ -9,16 +9,63 @@ import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { notify } from "@/lib/services/webhook";
// Only these columns may be patched from the client. Spreading the whole payload
// into `.set()` let a caller rewrite roomId/userId/extraData of any row, which
// is mass assignment and IDOR in one.
const ROOM_ITEM_FIELDS = [
"wallPos",
"x",
"y",
"z",
"rot",
"extraData",
"wiredData",
"limitedData",
"guildId",
] as const;
const ROOM_FIELDS = ["name", "description", "state", "usersMax"] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
function toPositiveInt(value: unknown): number | null {
const n = typeof value === "number" ? value : Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
}
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
const { roomId, itemId, ...data } = payload as {
roomId: number;
itemId: number;
[key: string]: unknown;
};
const roomId = toPositiveInt(payload.roomId);
const itemId = toPositiveInt(payload.itemId);
if (!roomId || !itemId) {
throw new Error("Invalid room or item id");
}
// The item must belong to the room the staff member is editing.
const [item] = await db
.select({ id: Items.id })
.from(Items)
.where(and(eq(Items.id, itemId), eq(Items.roomId, roomId)))
.limit(1);
if (!item) throw new Error("Item not found in this room");
await db
.update(Items)
.set(data as Partial<typeof Items.$inferInsert>)
.set(
pickAllowed(payload, ROOM_ITEM_FIELDS) as Partial<
typeof Items.$inferInsert
>,
)
.where(eq(Items.id, itemId));
await logStaffActivity({
staffId: staff.id,
@@ -117,24 +164,20 @@ export async function deleteRoom({ id }: { id: number }) {
revalidatePath("/admin/rooms");
}
export async function updateRoom({
id,
...data
}: {
id: number;
name?: string;
description?: string;
state?: string;
usersMax?: number;
}) {
export async function updateRoom({ id, ...data }: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
const roomId = toPositiveInt(id);
if (!roomId) throw new Error("Invalid room id");
await db
.update(Rooms)
.set(pickAllowed(data, ROOM_FIELDS) as Partial<typeof Rooms.$inferInsert>)
.where(eq(Rooms.id, roomId));
await logStaffActivity({
staffId: staff.id,
action: "room_update",
description: `Updated room #${id}`,
description: `Updated room #${roomId}`,
targetType: "room",
targetId: id,
targetId: roomId,
});
revalidatePath(`/admin/rooms/${id}`);
revalidatePath(`/admin/rooms/${roomId}`);
}
+29
View File
@@ -14,8 +14,13 @@ const state = vi.hoisted(() => ({
selectQueue: [] as Queue,
rows: [] as Array<Record<string, unknown>>,
failInsert: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
// The real moderation module loads the word filter through the (mocked) db
// select queue, which would shift the rows the forum assertions rely on.
vi.mock("@/lib/services/moderation", () => ({ isAllowed: state.isAllowed }));
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
@@ -210,6 +215,7 @@ describe("postThread", () => {
state.failInsert = false;
state.selectQueue = [];
state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
);
@@ -283,6 +289,18 @@ describe("postThread", () => {
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects content blocked by the word filter before hitting the db", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
state.selectQueue = [[{ id: 10 }]];
await expect(postThread(threadForm())).rejects.toThrow(
"/guilds/10/forum/new?error=invalid",
);
expect(state.isAllowed).toHaveBeenCalledWith(
"Welcome thread Hello from the community",
);
expect(state.insert).not.toHaveBeenCalled();
});
it("reports not_found when the guild does not exist", async () => {
state.selectQueue = [[]];
await expect(postThread(threadForm())).rejects.toThrow(
@@ -324,6 +342,7 @@ describe("replyToThread", () => {
state.failInsert = false;
state.selectQueue = [];
state.rows = [];
state.isAllowed.mockResolvedValue({ ok: true });
state.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) => fn(txDb),
);
@@ -361,6 +380,16 @@ describe("replyToThread", () => {
expect(state.update.mock.calls[0][1]).toMatchObject({ postsCount: 1 });
});
it("rejects a reply blocked by the word filter before hitting the db", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
state.selectQueue = [[{ id: 20, locked: 0, postsCount: 3 }]];
await expect(replyToThread(replyForm())).rejects.toThrow(
"/guilds/10/forum/20?error=invalid",
);
expect(state.isAllowed).toHaveBeenCalledWith("A thoughtful reply");
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects missing or non-positive ids by redirecting to /guilds", async () => {
await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow(
"/guilds",
+5
View File
@@ -13,6 +13,7 @@ import {
MessengerFriendships,
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
// guilds_forums_comments.message which is TEXT. Keep the first post's message
@@ -235,6 +236,8 @@ export async function postThread(formData: FormData): Promise<void> {
.slice(0, MESSAGE_MAX);
if (!subject || !message) {
outcome = "invalid";
} else if (!(await isAllowed(`${subject} ${message}`)).ok) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
@@ -326,6 +329,8 @@ export async function replyToThread(formData: FormData): Promise<void> {
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "invalid";
} else if (!(await isAllowed(message)).ok) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
+25 -2
View File
@@ -78,6 +78,22 @@ async function verifyTwoFactorCode(
export async function beginTwoFactor(): Promise<void> {
const id = await sessionUserId();
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
if (!(await rateLimit(`2fa-begin:${id}`, 5, 30_000)).ok)
redirect("/settings/2fa?error=ratelimit");
// Re-running this action while 2FA is confirmed would be a silent *downgrade*
// (the new secret is stored unconfirmed, and unconfirmed means "login gate
// off"), so the existing setup has to be disabled through the proper flow
// first: a valid code, not just an authenticated session.
const [current] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
.from(User)
.where(eq(User.id, id))
.limit(1);
if (current?.twoFactorConfirmedAt)
redirect("/settings/2fa?error=alreadyenabled");
const secret = generateTotpSecret();
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
const codes = generateRecoveryCodes();
@@ -104,12 +120,19 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
.normalize("NFC")
.trim();
const { ok } = await verifyTwoFactorCode(id, code);
const { ok, updatedRecoveryCodes } = await verifyTwoFactorCode(id, code);
if (!ok) redirect("/settings/2fa?error=badcode");
// A recovery code spends itself on use, so persist the remainder together
// with the confirmation instead of dropping the caller's own update.
await db
.update(User)
.set({ twoFactorConfirmedAt: new Date() })
.set({
twoFactorConfirmedAt: new Date(),
...(updatedRecoveryCodes !== undefined
? { twoFactorRecoveryCodes: updatedRecoveryCodes }
: {}),
})
.where(eq(User.id, id));
redirect("/settings/2fa?enabled=1");
}
+18
View File
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
updateCall: undefined as unknown,
rconSetMotto: vi.fn(),
failDbUpdate: false,
isAllowed: vi.fn(async () => ({ ok: true })),
}));
const databaseErrorClass = vi.hoisted(
@@ -63,6 +64,10 @@ vi.mock("@/lib/services/rcon", () => ({
rcon: { setMotto: state.rconSetMotto },
}));
vi.mock("@/lib/services/moderation", () => ({
isAllowed: state.isAllowed,
}));
const mockRevalidatePath = vi.hoisted(() => vi.fn());
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
@@ -98,6 +103,7 @@ beforeEach(() => {
state.updateCall = undefined;
state.rconSetMotto.mockResolvedValue(true);
state.failDbUpdate = false;
state.isAllowed.mockResolvedValue({ ok: true });
});
describe("updateMotto", () => {
@@ -141,6 +147,18 @@ describe("updateMotto", () => {
});
});
describe("updateMotto word filter", () => {
it("rejects a motto blocked by the word filter before persisting", async () => {
state.isAllowed.mockResolvedValue({ ok: false, reason: "bad" });
await expect(updateMotto(mockingForm("bad motto"))).rejects.toThrow(
databaseErrorClass,
);
expect(state.isAllowed).toHaveBeenCalledWith("bad motto");
expect(state.updateCall).toBeUndefined();
expect(state.rconSetMotto).not.toHaveBeenCalled();
});
});
describe("updateMottoAction", () => {
it("denies unauthenticated callers", async () => {
state.auth.mockResolvedValue(null);
+8
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { db, User } from "@/lib/db";
import { actionOk, authAction } from "@/lib/foundation/action";
import { DatabaseError } from "@/lib/foundation/errors";
import { isAllowed } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
const MOTTO_MAX = 127;
@@ -16,7 +17,14 @@ const mottoSchema = z.object({
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
});
async function assertMottoAllowed(motto: string): Promise<void> {
if (!(await isAllowed(motto)).ok) {
throw new DatabaseError("Motto not allowed");
}
}
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
await assertMottoAllowed(ctx.data.motto);
try {
await db
.update(User)
+7
View File
@@ -62,6 +62,9 @@ vi.mock("@/lib/permissions", () => ({
USERS_BAN: "users.ban",
USERS_RESET_PASSWORD: "users.reset_password",
},
// Staff in the fixtures is rank 7 and the hotel's top rank is 10, so rank
// guards act as "below-your-own-rank only".
getHighestRank: vi.fn(() => Promise.resolve(10)),
}));
vi.mock("@/lib/safe-action", () => ({
@@ -77,6 +80,10 @@ vi.mock("@/lib/services/audit", () => ({
logAudit: vi.fn(),
}));
vi.mock("@/lib/auth/session-revocation", () => ({
revokeUserCredentials: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/services/webhook", () => ({
notify: vi.fn(),
}));
+36 -5
View File
@@ -3,8 +3,10 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import {
Ban,
db,
@@ -13,7 +15,7 @@ import {
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { getHighestRank, PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
@@ -54,8 +56,9 @@ export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
const actorRank = ctx.session.user.rank;
const highestRank = await getHighestRank();
if (rank >= actorRank && !isDynamicSuperAdmin(actorRank, highestRank)) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
@@ -130,7 +133,7 @@ export const updateUser = adminAction(
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
!isDynamicSuperAdmin(ctx.session.user.rank, await getHighestRank())
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
@@ -144,7 +147,15 @@ export const updateUser = adminAction(
motto: string;
credits: number;
pixels: number;
mailVerified?: string;
}>;
// A changed address has to prove itself again: leaving mail_verified
// set would keep every mail send (resets, notifications) pointed at an
// inbox nobody confirmed, and would silently bypass the "verified
// accounts only" gate.
if (patch.mail !== undefined && patch.mail !== targetUser.mail) {
patch.mailVerified = "0";
}
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
@@ -331,7 +342,14 @@ async function guardRank(targetUserId: number, sessionRank: number) {
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
// The owner is whoever holds the hotel's highest rank *today*. The old
// `sessionRank < 7` shortcut handed every rank-7 account owner powers on
// any hotel whose top rank is 8+, which makes it a plain escalation.
const highestRank = await getHighestRank();
if (
target.rank >= sessionRank &&
!isDynamicSuperAdmin(sessionRank, highestRank)
) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
@@ -358,6 +376,9 @@ export const resetPassword = adminAction(
.update(User)
.set({ password: hashed })
.where(eq(User.id, ctx.data.userId));
// A staff-issued password must also end the user's live sessions: this
// action exists precisely for "account compromised" situations.
await revokeUserCredentials(ctx.data.userId);
invalidateLoginCache(target.username);
logAudit({
@@ -419,9 +440,19 @@ const alertUserSchema = z.object({
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message, username: target.username },
});
return actionOk();
},
);
+186
View File
@@ -0,0 +1,186 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
clientIp: vi.fn(async () => "203.0.113.7"),
rateLimit: vi.fn(async () => ({ ok: true, retryAfter: 0 })),
captchaConfig: vi.fn(async () => ({ provider: "none", field: "" })),
// Mirrors the real verifier: a missing token never passes.
verifyCaptcha: vi.fn(async (token: string | null) => Boolean(token)),
sendVerification: vi.fn(async () => undefined),
rows: [] as Array<Record<string, unknown>>,
rateLimitedFor: null as string | null,
failDb: false,
}));
vi.mock("@/lib/rate-limit", () => ({
clientIp: state.clientIp,
rateLimit: vi.fn(async (key: string) => {
state.rateLimitedFor = key;
return state.rateLimit();
}),
}));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: state.captchaConfig,
verifyCaptcha: state.verifyCaptcha,
}));
vi.mock("@/lib/auth/email-verification", () => ({
sendVerification: state.sendVerification,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
return {
...schema,
db: createFakeDb(() => {
if (state.failDb) throw new Error("db down");
return state.rows;
}),
};
});
import { resendVerification } from "./verify";
const form = (fields: Record<string, string>) => {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.set(k, v);
return f;
};
const prev = { ok: false, error: null };
beforeEach(() => {
vi.clearAllMocks();
state.clientIp.mockResolvedValue("203.0.113.7");
state.rateLimit.mockResolvedValue({ ok: true, retryAfter: 0 });
state.captchaConfig.mockResolvedValue({ provider: "none", field: "" });
state.verifyCaptcha.mockImplementation(async (t) => Boolean(t));
state.sendVerification.mockResolvedValue(undefined);
state.rows = [];
state.rateLimitedFor = null;
state.failDb = false;
});
describe("resendVerification", () => {
it("rejects a malformed address", async () => {
const res = await resendVerification(prev, form({ email: "nope" }));
expect(res).toEqual({ ok: false, error: "invalid" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("sends for an unverified account", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({ email: "[email protected]" }),
);
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("answers identically for an unknown address so it cannot be probed", async () => {
state.rows = [];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("does not mail an already verified account", async () => {
state.rows = [{ id: 5, mailVerified: "1" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rate limits on the ip", async () => {
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 60 });
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "rateLimited" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rate limits on the address so rotating ips cannot mail-bomb", async () => {
state.rateLimit
.mockResolvedValueOnce({ ok: true, retryAfter: 0 })
.mockResolvedValueOnce({ ok: false, retryAfter: 300 });
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "rateLimited" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("reports unavailable when the lookup throws", async () => {
state.failDb = true;
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "unavailable" });
});
});
describe("resendVerification captcha", () => {
beforeEach(() => {
state.captchaConfig.mockResolvedValue({
provider: "turnstile",
field: "cf-turnstile-response",
});
});
it("rejects a missing token when a provider is configured", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: false, error: "captcha" });
expect(state.verifyCaptcha).toHaveBeenCalledWith(null, "203.0.113.7");
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("rejects a failing token", async () => {
state.verifyCaptcha.mockResolvedValue(false);
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({
email: "[email protected]",
"cf-turnstile-response": "bad-token",
}),
);
expect(res).toEqual({ ok: false, error: "captcha" });
expect(state.sendVerification).not.toHaveBeenCalled();
});
it("accepts a valid token and mails the account", async () => {
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(
prev,
form({ email: "[email protected]", "cf-turnstile-response": "good-token" }),
);
expect(res).toEqual({ ok: true, error: null });
expect(state.verifyCaptcha).toHaveBeenCalledWith(
"good-token",
"203.0.113.7",
);
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("checks the captcha before the account lookup", async () => {
state.verifyCaptcha.mockResolvedValue(false);
state.rows = [{ id: 5, mailVerified: "0" }];
await resendVerification(prev, form({ email: "[email protected]" }));
const order: string[] = [];
state.verifyCaptcha.mockImplementation(async () => {
order.push("captcha");
return false;
});
await resendVerification(prev, form({ email: "[email protected]" }));
order.push("done");
expect(order).toEqual(["captcha", "done"]);
});
it("does not verify a captcha when no provider is configured", async () => {
state.captchaConfig.mockResolvedValue({ provider: "none", field: "" });
state.rows = [{ id: 5, mailVerified: "0" }];
const res = await resendVerification(prev, form({ email: "[email protected]" }));
expect(res).toEqual({ ok: true, error: null });
expect(state.verifyCaptcha).not.toHaveBeenCalled();
});
});
+74
View File
@@ -0,0 +1,74 @@
"use server";
import { eq } from "drizzle-orm";
import { sendVerification } from "@/lib/auth/email-verification";
import { db, User } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
export interface ResendVerificationState {
ok: boolean;
error: string | null;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Re-send a verification e-mail for an address the visitor typed on /verify.
*
* Deliberately reports success even when no matching unverified account exists:
* a distinct failure would let anyone probe which addresses are registered. The
* identical-privacy behaviour also applies to the e-mail templates, which are
* only sent for real accounts. Rate limiting plus captcha are the spam defence:
* this endpoint triggers real outbound mail, so an unverified address must not
* be usable as a free mail cannon.
*/
export async function resendVerification(
_prevState: ResendVerificationState,
formData: FormData,
): Promise<ResendVerificationState> {
const email = String(formData.get("email") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!EMAIL_RE.test(email)) {
return { ok: false, error: "invalid" };
}
const ip = await clientIp();
if (!(await rateLimit(`verify:resend:${ip}`, 3, 10 * 60_000)).ok) {
return { ok: false, error: "rateLimited" };
}
// Same cooldown keyed on the address, so rotating IPs cannot be used to
// mail-bomb an arbitrary inbox with "verify your email".
if (!(await rateLimit(`verify:resend:email:${email}`, 3, 10 * 60_000)).ok) {
return { ok: false, error: "rateLimited" };
}
// Captcha runs before any lookup or send, and answers with the same
// `captcha` code the login form uses so the UI can point at the widget.
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "")
.normalize("NFC")
.trim();
if (!(await verifyCaptcha(token || null, ip))) {
return { ok: false, error: "captcha" };
}
}
try {
const [user] = await db
.select({ id: User.id, mailVerified: User.mailVerified })
.from(User)
.where(eq(User.mail, email))
.limit(1);
if (user && user.mailVerified !== "1") {
await sendVerification(email);
}
} catch {
return { ok: false, error: "unavailable" };
}
return { ok: true, error: null };
}
+2 -2
View File
@@ -110,7 +110,7 @@ export default async function ApplyStaffPage({
const appliedRankIds = new Set(myApps.map((a) => a.rankId));
return (
<main className="page-grid">
<section className="page-grid">
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
@@ -233,6 +233,6 @@ export default async function ApplyStaffPage({
})}
</div>
)}
</main>
</section>
);
}
+2 -2
View File
@@ -89,7 +89,7 @@ export default async function ApplyTeamPage({
const appliedTeamIds = new Set(myApps.map((a) => a.rankId));
return (
<main className="page-grid">
<section className="page-grid">
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
@@ -199,6 +199,6 @@ export default async function ApplyTeamPage({
})}
</div>
)}
</main>
</section>
);
}
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function BadgesPage() {
.catch(() => []);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🏅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={badges.length === 0}>
@@ -76,6 +76,6 @@ export default async function BadgesPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -52,7 +52,7 @@ export default async function BannedPage() {
});
return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard icon="🚫" title={t("title")} subtitle={t("subtitle")}>
<p style={{ marginTop: 0 }}>{t("body")}</p>
{reason ? (
@@ -65,6 +65,6 @@ export default async function BannedPage() {
{t("contactStaff")}
</p>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -54,7 +54,7 @@ export default function CommunityPage() {
const t = useTranslations("pages.community");
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🌍" title={t("title")} subtitle={t("subtitle")} />
<div className="card-grid sm-2 lg-3">
@@ -84,6 +84,6 @@ export default function CommunityPage() {
</Link>
))}
</div>
</main>
</section>
);
}
+2 -2
View File
@@ -398,7 +398,7 @@ export default function DevelopersPage() {
const totalEndpoints = GROUPS.reduce((n, g) => n + g.endpoints.length, 0);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard
icon="🧩"
title="Developer API"
@@ -479,6 +479,6 @@ export default function DevelopersPage() {
</div>
</ContentCard>
))}
</main>
</section>
);
}
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function DrawBadgePage({
}
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard
icon="🎨"
title="Draw a Badge"
@@ -231,6 +231,6 @@ export default async function DrawBadgePage({
</div>
)}
</ContentCard>
</main>
</section>
);
}
@@ -33,6 +33,8 @@ export function EventRegisterButton({
run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"),
errorMessage: t("registerError"),
// registerForEvent revalidates /events and /events/<id>.
revalidated: true,
})
}
>
+2 -2
View File
@@ -142,7 +142,7 @@ export default async function EventDetailPage({
else if (isFull) disabledReason = t("eventFull");
return (
<main className="page-grid">
<section className="page-grid">
<p className="muted" style={{ margin: 0 }}>
<Link href="/events">{t("back")}</Link>
</p>
@@ -259,6 +259,6 @@ export default async function EventDetailPage({
</ul>
</ContentCard>
) : null}
</main>
</section>
);
}
+2 -2
View File
@@ -69,7 +69,7 @@ async function EventsPage({
const href = (page: number) =>
`/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`;
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard>
@@ -272,7 +272,7 @@ async function EventsPage({
</nav>
</ContentCard>
)}
</main>
</section>
);
}
+42 -5
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset";
@@ -6,6 +7,15 @@ import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
import { captchaConfig } from "@/lib/services/captcha";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.forgot");
return {
title: t("title"),
description: t("subtitle"),
robots: { index: false, follow: false },
};
}
export default async function ForgotPage({
searchParams,
}: {
@@ -17,12 +27,39 @@ export default async function ForgotPage({
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<section style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard icon="🔑" title={t("title")} subtitle={t("subtitle")}>
{sent ? (
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
{t("sentNotice")}
</p>
<>
<p className="muted" style={{ textAlign: "center", margin: 0 }}>
{t("sentNotice")}
</p>
{/* A mail that never arrived must be retryable from here,
otherwise the visitor is stuck on a dead end. */}
<form
action={requestReset}
style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}
>
<input
name="email"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
required
/>
<CaptchaWidget
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
<button type="submit" className="btn btn-primary">
{t("sendAnotherLink")}
</button>
</form>
</>
) : (
<form
action={requestReset}
@@ -66,6 +103,6 @@ export default async function ForgotPage({
<Link href="/login">{t("backToLogin")}</Link>
</p>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -102,7 +102,7 @@ export default async function FriendsPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{removed === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.removed")}
@@ -180,6 +180,6 @@ export default async function FriendsPage({
</div>
)}
</ContentCard>
</main>
</section>
);
}
@@ -103,11 +103,11 @@ export default async function GuildForumThreadPage({
} catch (error) {
publicReadFailure("guild.thread")(error);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
</ContentCard>
</main>
</section>
);
}
@@ -173,7 +173,7 @@ export default async function GuildForumThreadPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
@@ -317,6 +317,6 @@ export default async function GuildForumThreadPage({
{t("loginToReply")} <Link href="/login">{t("loginLink")}</Link>
</p>
)}
</main>
</section>
);
}
@@ -70,7 +70,7 @@ export default async function NewThreadPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
@@ -132,6 +132,6 @@ export default async function NewThreadPage({
</div>
</form>
</ContentCard>
</main>
</section>
);
}
+4 -4
View File
@@ -67,11 +67,11 @@ export default async function GuildForumPage({
} catch (error) {
publicReadFailure("guild.forum")(error);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}/forum`} />
</ContentCard>
</main>
</section>
);
}
@@ -135,7 +135,7 @@ export default async function GuildForumPage({
const usernameById = new Map(users.map((u) => [u.id, u.username]));
return (
<main className="page-grid">
<section className="page-grid">
{posted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
@@ -240,6 +240,6 @@ export default async function GuildForumPage({
</table>
)}
</ContentCard>
</main>
</section>
);
}
+4 -4
View File
@@ -55,11 +55,11 @@ export default async function GuildPage({
} catch (error) {
publicReadFailure("guild.detail")(error);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard>
<PublicLoadError href={`/guilds/${guildId}`} />
</ContentCard>
</main>
</section>
);
}
@@ -139,7 +139,7 @@ export default async function GuildPage({
const created = formatDate(new Date(guild.dateCreated * 1000), "date");
return (
<main className="page-grid">
<section className="page-grid">
<p style={{ margin: 0 }}>
<Link href="/guilds">{t("allGuilds")}</Link>
</p>
@@ -251,6 +251,6 @@ export default async function GuildPage({
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -50,7 +50,7 @@ export default async function GuildsPage() {
const guilds = await getGuilds();
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={!guilds?.length}>
@@ -98,6 +98,6 @@ export default async function GuildsPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -103,7 +103,7 @@ export default async function HelpCategoryPage({
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
<main className="page-grid">
<section className="page-grid">
<p style={{ margin: 0 }}>
<Link href="/help">{t("back")}</Link>
</p>
@@ -162,6 +162,6 @@ export default async function HelpCategoryPage({
</div>
</ContentCard>
) : null}
</main>
</section>
);
}
+2 -2
View File
@@ -127,7 +127,7 @@ export default async function HelpCenterPage() {
}
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard
icon="❓"
title={t("title")}
@@ -278,6 +278,6 @@ export default async function HelpCenterPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -157,7 +157,7 @@ export default async function HelpTicketDetailPage({
];
return (
<main className="page-grid">
<section className="page-grid">
{replied === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.replied")}
@@ -302,6 +302,6 @@ export default async function HelpTicketDetailPage({
{t("closedHint")}
</p>
)}
</main>
</section>
);
}
+2 -2
View File
@@ -67,7 +67,7 @@ export default async function HelpTicketsPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{created === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.created")}
@@ -167,6 +167,6 @@ export default async function HelpTicketsPage({
</table>
)}
</ContentCard>
</main>
</section>
);
}
+28 -16
View File
@@ -1,4 +1,6 @@
import dynamic from "next/dynamic";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { CloudsField } from "@/components/clouds-field";
import MotionPageWrapper from "@/components/motion-page-wrapper";
@@ -7,6 +9,7 @@ import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
import { publicClientMessages } from "@/lib/i18n-client-messages";
const RadioPlayerGate = dynamic(
() => import("@/components/public/radio-player-gate"),
@@ -20,30 +23,39 @@ const RadioPlayerGate = dynamic(
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*
* The message provider lives here rather than only in the root layout: nested
* NextIntlClientProviders replace the parent set instead of merging, so this is
* where the public catalogue is installed — without the ~177 KB of staff-tool
* namespaces that no page in this group can reach.
*/
export default async function SiteLayout({
children,
}: {
children: ReactNode;
}) {
const session = await auth();
const [session, locale, messages] = await Promise.all([
auth(),
getLocale(),
getMessages(),
]);
const clientMessages = publicClientMessages(messages);
return (
<>
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField />
{session?.user?.id ? (
<>
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
</>
) : null}
{/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way to
reach any other page at all. */}
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
<main>
<div
data-theme-block="content_grid"
@@ -62,6 +74,6 @@ export default async function SiteLayout({
<div data-theme-block="radio_player">
<RadioPlayerGate />
</div>
</>
</NextIntlClientProvider>
);
}
+26 -9
View File
@@ -10,6 +10,7 @@ import {
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.leaderboard");
@@ -52,7 +53,17 @@ function formatValue(key: TabKey, value: number): string {
return value.toLocaleString();
}
type Row = { username: string; look: string; value: number };
type Row = { userId: number; username: string; look: string; value: number };
/**
* Users who hid their wallet must not appear in the currency tabs: the profile
* page already honours that, and a leaderboard that ignores it makes the
* setting meaningless.
*/
async function withoutHiddenWallets(rows: Row[]): Promise<Row[]> {
const privacy = await loadProfilePrivacyMap(rows.map((r) => r.userId));
return rows.filter((r) => privacy.get(r.userId)?.wallet !== false);
}
async function loadCreditsRows(): Promise<Row[]> {
try {
@@ -62,6 +73,7 @@ async function loadCreditsRows(): Promise<Row[]> {
() =>
db
.select({
id: User.id,
username: User.username,
look: User.look,
credits: User.credits,
@@ -71,11 +83,14 @@ async function loadCreditsRows(): Promise<Row[]> {
.limit(20),
{ staleMs: 120000 },
);
return users.map((u) => ({
username: u.username,
look: u.look,
value: u.credits,
}));
return await withoutHiddenWallets(
users.map((u) => ({
userId: u.id,
username: u.username,
look: u.look,
value: u.credits,
})),
);
} catch {
return [];
}
@@ -89,6 +104,7 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
() =>
db
.select({
userId: User.id,
username: User.username,
look: User.look,
value: UsersCurrency.amount,
@@ -99,7 +115,7 @@ async function loadCurrencyRows(type: number): Promise<Row[]> {
.orderBy(desc(UsersCurrency.amount))
.limit(20),
{ staleMs: 120000 },
);
).then(withoutHiddenWallets);
} catch {
return [];
}
@@ -116,6 +132,7 @@ async function loadSettingsRows(
() =>
db
.select({
userId: User.id,
username: User.username,
look: User.look,
value: column,
@@ -166,7 +183,7 @@ export default async function LeaderboardPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard
icon="📊"
title={t("title")}
@@ -245,6 +262,6 @@ export default async function LeaderboardPage({
</div>
)}
</ContentCard>
</main>
</section>
);
}
+196 -198
View File
@@ -1,20 +1,47 @@
import { count, desc, eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { LoginForm } from "@/components/auth/login-form";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
export default async function LoginPage() {
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
const title = t("title");
const description = t("subtitle");
return {
title,
description,
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
robots: { index: false, follow: false },
openGraph: { title, description, type: "website" },
};
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{
from?: string;
registered?: string;
reset?: string;
}>;
}) {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
@@ -23,202 +50,173 @@ export default async function LoginPage() {
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
// Already signed in: the form has nothing to do here. Honour `from` first so
// an admin bounced off /admin lands back where they were heading.
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
const [online, recentUsers, latestUsers] = await Promise.all([
cached("online_count", 10_000, () =>
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8)
.catch(() => []),
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8)
.catch(() => []),
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists.
const notice =
sp.registered === "1" ? (
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("registeredSuccess")}
</p>
) : sp.reset === "1" ? (
// `?reset=1` comes from a successful password reset; saying so matters
// because the visitor just changed their password and a silent form
// reads like the reset failed.
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("passwordChanged")}
</p>
) : undefined;
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<AuthTopBar hotelName={hotelName} logo={logo} />
<Reveal>
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
{/* Left panel */}
<div className="lg:w-96 shrink-0 space-y-4">
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden"
>
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
priority
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
background:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
color:
"var(--color-primary-readable, var(--color-primary))",
}}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1 mx-auto max-w-[200px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
{recentUsers.length > 0 && (
<SurfaceCard
title={t("whoIsOnline")}
icon={ICON_NAV_GOODY}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
}}
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span
className="w-full truncate text-center text-[9px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)}
{latestUsers.length > 0 && (
<SurfaceCard
title={t("newestCitizens")}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{latestUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
}}
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
className="rounded-lg"
/>
<span
className="w-full truncate text-center text-[9px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)}
</div>
{/* Right: form */}
<div className="flex-1 lg:sticky lg:top-6">
<SurfaceCard
title={t("title")}
icon={ICON_NAV_ME}
bodyClassName="p-6 sm:p-7"
>
<p
className="text-sm mb-6"
style={{ color: "var(--color-text-muted)" }}
>
{t("subtitle")}
</p>
<LoginForm
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
</SurfaceCard>
</div>
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
form={
<LoginForm
redirectTo={redirectTo}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
</Reveal>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
);
}
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function LogoPage() {
),
);
return (
<main
<section
style={{
display: "grid",
gap: "1.5rem",
@@ -45,6 +45,6 @@ export default async function LogoPage() {
/>
<LogoGenerator initialText={initialText} canSaveToSite={canSaveToSite} />
</main>
</section>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ export default async function MaintenancePage() {
]);
return (
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
<section style={{ maxWidth: 560, margin: "2rem auto" }}>
<ContentCard
icon="🛠️"
title={t("title", { hotel })}
@@ -25,6 +25,6 @@ export default async function MaintenancePage() {
{t("staffCanLogIn")}
</p>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -82,7 +82,7 @@ export default async function MarketplacePage() {
const total = offers.reduce((sum, o) => sum + o.price, 0);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🛍️" title={t("title")} subtitle={t("subtitle")}>
<div className="stat-grid">
<StatBlock
@@ -148,6 +148,6 @@ export default async function MarketplacePage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+8 -6
View File
@@ -47,14 +47,14 @@ async function MePage({
data = await loadUserDashboard(userId);
} catch {
return (
<main>
<section>
<SurfaceCard className="p-6">
<p role="alert">{t("loadError")}</p>
<Link href="/me" className="btn btn-outline">
{t("retry")}
</Link>
</SurfaceCard>
</main>
</section>
);
}
// Daily reward state (settings + schedule + the user's last claim). Never
@@ -63,14 +63,14 @@ async function MePage({
const user = data.userRows[0];
if (!user)
return (
<main>
<section>
<SurfaceCard className="p-6">
<p role="alert">{t("loadError")}</p>
<Link href="/login" className="btn btn-outline">
{t("login")}
</Link>
</SurfaceCard>
</main>
</section>
);
const {
hotelName,
@@ -145,7 +145,7 @@ async function MePage({
? error
: "error";
return (
<main className={styles.dashboard}>
<section className={styles.dashboard}>
{claimed && (
<p role="status" className={styles.feedback}>
{t("claimed")}
@@ -181,6 +181,8 @@ async function MePage({
width={100}
height={140}
className={styles.avatar}
loading="eager"
fetchPriority="high"
/>
<div className={styles.identity}>
<p className="muted">{t("welcome", { hotel: hotelName })}</p>
@@ -469,7 +471,7 @@ async function MePage({
</SurfaceCard>
</aside>
</div>
</main>
</section>
);
}
+2 -2
View File
@@ -177,7 +177,7 @@ export default async function MessagesPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{accepted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.accepted")}
@@ -391,6 +391,6 @@ export default async function MessagesPage({
</div>
)}
</ContentCard>
</main>
</section>
);
}
+14 -5
View File
@@ -94,7 +94,7 @@ async function ArticlePage({
} catch {
logger.error("Public article lookup failed", { module: "news" });
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="📰" title={t("loadError")}>
<p role="alert">{t("loadError")}</p>
<a
@@ -104,7 +104,7 @@ async function ArticlePage({
{t("retry")}
</a>
</ContentCard>
</main>
</section>
);
}
if (!article) notFound();
@@ -159,7 +159,7 @@ async function ArticlePage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{comment === "posted" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
@@ -196,7 +196,16 @@ async function ArticlePage({
src={article.image}
alt=""
decoding="async"
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
loading="eager"
// Reserve the box: an unbounded hero image shifts the whole
// article down once the bitmap decodes.
style={{
width: "100%",
aspectRatio: "16 / 9",
objectFit: "cover",
borderRadius: 10,
margin: "0 0 1rem",
}}
/>
) : null}
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
@@ -414,7 +423,7 @@ async function ArticlePage({
)}
</ContentCard>
</section>
</main>
</section>
);
}
+2 -2
View File
@@ -31,7 +31,7 @@ async function NewsPage({
`/news?${new URLSearchParams({ q: result?.search ?? params.q ?? "", order: result?.order ?? "newest", page: String(page) })}`;
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="📰" title={t("title")} subtitle={t("subtitle")} />
<ContentCard>
@@ -186,7 +186,7 @@ async function NewsPage({
</nav>
</ContentCard>
)}
</main>
</section>
);
}
+237 -158
View File
@@ -1,4 +1,5 @@
import { count, desc, eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { ArrowRight, ChevronDown } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
@@ -6,7 +7,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { HomeLoginForm } from "@/components/auth/home-login-form";
import { LoginForm } from "@/components/auth/login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
@@ -14,6 +15,7 @@ import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
@@ -23,7 +25,10 @@ import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
import {
cachedOnlineCount,
countArticles,
countPhotos,
countRooms,
countUsers,
@@ -40,17 +45,17 @@ import {
ICON_NAV_ME,
} from "@/lib/site-icons";
export const metadata: Metadata = {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
openGraph: {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
type: "website",
},
};
export async function generateMetadata(): Promise<Metadata> {
const tn = await getTranslations("nav");
const tp = await getTranslations("pages.home");
const title = tn("home");
const description = tp("welcomeBody");
return {
title,
description,
openGraph: { title, description, type: "website" },
};
}
// ── Local visual language (theme-aware, no big white blocks) ──────────
const PANEL_BG =
@@ -175,17 +180,12 @@ async function getHotelData() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
] = await Promise.all([
cached("online_count", 10_000, () =>
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.online")),
cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"),
),
@@ -195,6 +195,9 @@ async function getHotelData() {
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
publicReadFailure("home.photos-count"),
),
cached("total_articles", 300_000, countArticles, {
staleMs: 300000,
}).catch(publicReadFailure("home.articles-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
@@ -203,7 +206,7 @@ async function getHotelData() {
15_000,
() =>
db
.select({ username: User.username, look: User.look })
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(12),
@@ -228,6 +231,7 @@ async function getHotelData() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
@@ -249,12 +253,24 @@ export default async function Home() {
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
} = await getHotelData();
// Users who hide their online state must not surface in the "who is online"
// rails on the homepage either.
const recentUserPrivacy = await loadProfilePrivacyMap(
(rawRecentUsers ?? []).map((u) => u.id),
);
const recentUsers =
rawRecentUsers === null
? null
: rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false,
);
const captcha = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
@@ -310,7 +326,7 @@ export default async function Home() {
className="text-sm font-bold"
style={{ color: "rgba(255,255,255,0.95)" }}
>
→
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<div
@@ -342,12 +358,6 @@ export default async function Home() {
>
{a.title}
</h3>
<p
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
style={{ color: "#cbd5e1" }}
>
{formatDate(a.createdAt, "date", "")}
</p>
</div>
</div>
</Link>
@@ -371,7 +381,7 @@ export default async function Home() {
color: "var(--color-primary)",
},
{
value: articles?.length ?? null,
value: articleCount,
label: tp("statsArticles"),
color: "var(--color-accent)",
},
@@ -484,6 +494,29 @@ export default async function Home() {
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
}}
/>
{/* Drifting brand-tinted aurora behind the copy */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
opacity: 0.5,
}}
/>
<div
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
style={{
background:
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
animationDelay: "-7s",
opacity: 0.45,
}}
/>
</div>
<div
aria-hidden="true"
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
@@ -553,7 +586,16 @@ export default async function Home() {
</p>
{recentUsers !== null && recentUsers.length > 0 && (
<div className="mt-7 flex items-center justify-center gap-3">
<div
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
style={{
background: "rgba(255,255,255,0.08)",
borderColor: "rgba(255,255,255,0.22)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
animationDelay: "0.08s",
}}
>
<div className="flex -space-x-2.5">
{recentUsers.slice(0, 4).map((u) => (
<UserAvatarThumbnail
@@ -566,25 +608,21 @@ export default async function Home() {
))}
</div>
<span
className="text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.72)" }}
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.75)" }}
>
{tp("recentUsers")}
</span>
</div>
)}
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
>
<Link
href="/register"
className="btn-shine inline-flex items-center gap-2.5 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wider transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
style={{
background:
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
color: "var(--color-primary-foreground)",
boxShadow:
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 55%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-primary) 55%, transparent)",
}}
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
>
<Image
src="/assets/images/EnterHubbly.png"
@@ -597,21 +635,23 @@ export default async function Home() {
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 ease-out hover:-translate-y-1 hover:brightness-110 active:scale-95"
style={{
color: "#fff",
background: "rgba(255,255,255,0.07)",
border: "1px solid rgba(255,255,255,0.24)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
className="btn-glass-dark px-8 py-4 text-sm font-bold"
>
{th("login")} →
{th("login")}
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
>
<ArrowRight className="h-4 w-4" />
</span>
</Link>
</div>
{/* Floating stat chips */}
<div className="mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3">
<div
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
style={{ animationDelay: "0.32s" }}
>
{statChips.map((s) => (
<span key={s.label} className="glass-chip">
<span
@@ -633,6 +673,28 @@ export default async function Home() {
</div>
</div>
{/* Scroll cue */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
style={{ zIndex: 2 }}
>
<div
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.09)",
border: "1px solid rgba(255,255,255,0.3)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<ChevronDown
className="h-5 w-5"
style={{ color: "rgba(255,255,255,0.88)" }}
/>
</div>
</div>
{/* Floating mascot — decorative, hidden on smaller screens. */}
<div
aria-hidden="true"
@@ -645,7 +707,6 @@ export default async function Home() {
alt=""
width={96}
height={128}
priority
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
@@ -661,32 +722,29 @@ export default async function Home() {
aria-label={tp("exploreLabel")}
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
>
{exploreLinks.map((l) => (
<Link
key={l.href}
href={l.href}
className="group inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-xs font-bold transition-all duration-300 hover:-translate-y-0.5 sm:text-sm"
style={{
background:
"color-mix(in srgb, var(--color-surface) 55%, transparent)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
color: "var(--color-text-readable)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
boxShadow:
"0 2px 8px -2px color-mix(in srgb, #000 8%, transparent)",
}}
>
{l.label}
{exploreLinks.map((l, i) => (
<Link key={l.href} href={l.href} className="explore-pill group">
<span
className="-translate-x-1 text-[10px] opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
aria-hidden="true"
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background:
i % 2 === 0
? "var(--color-primary)"
: "var(--color-accent)",
boxShadow: `0 0 8px color-mix(in srgb, ${
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
} 70%, transparent)`,
}}
/>
{l.label}
<ArrowRight
aria-hidden="true"
className="pill-arrow h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
→
</span>
/>
</Link>
))}
</nav>
@@ -711,9 +769,9 @@ export default async function Home() {
const accentVar =
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
return (
<div
<SpotlightCard
key={f.title}
className="card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
className="spotlight card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
style={{
background: GLASS_TILE_BG,
backdropFilter: "blur(16px)",
@@ -738,8 +796,8 @@ export default async function Home() {
<div
className="relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
style={{
background: `color-mix(in srgb, ${accentVar} 16%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 22%, transparent)`,
background: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, transparent)`,
borderRadius: 14,
}}
>
@@ -766,7 +824,7 @@ export default async function Home() {
>
{f.desc}
</p>
</div>
</SpotlightCard>
);
})}
</div>
@@ -787,64 +845,73 @@ export default async function Home() {
</h2>
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-8 lg:grid-cols-4">
{[
{ value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS },
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
{
value: totalPhotos,
label: tp("statsPhotos"),
icon: ICON_CAMERA,
},
{
value: articles?.length ?? null,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
},
].map((s) => (
<div
key={s.label}
className="group relative flex flex-col items-center gap-2.5 text-center"
>
<div
className="card-hairline overflow-hidden rounded-2xl border"
style={PANEL_STYLE}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-8 lg:grid-cols-4">
{[
{
value: users,
label: tp("statsCitizens"),
icon: ICON_FRIENDS,
},
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
{
value: totalPhotos,
label: tp("statsPhotos"),
icon: ICON_CAMERA,
},
{
value: articleCount,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
},
].map((s) => (
<div
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
key={s.label}
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
<div
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</div>
<div
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
color: "var(--color-text-muted)",
}}
>
{s.label}
</span>
</div>
<div
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
color: "var(--color-text-muted)",
}}
>
{s.label}
</span>
</div>
))}
))}
</div>
</div>
</div>
</Reveal>
@@ -870,7 +937,9 @@ export default async function Home() {
icon={ICON_NAV_ME}
bodyClassName="p-3.5 sm:p-4"
>
<HomeLoginForm
<LoginForm
variant="compact"
redirectTo="/"
captcha={{
provider: captcha.provider,
siteKey: captcha.siteKey || undefined,
@@ -963,12 +1032,10 @@ export default async function Home() {
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
{recentPhotos.slice(0, 4).map((p) => (
{recentPhotos.slice(0, 4).map((p, i) => (
<Link
key={p.id}
href="/photos"
title=""
aria-label={tp("recentPhotos")}
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
style={{
borderColor:
@@ -977,7 +1044,7 @@ export default async function Home() {
>
<Image
src={p.url}
alt={tp("recentPhotos")}
alt={`${tp("recentPhotos")} ${i + 1}`}
width={280}
height={210}
className="h-full w-full object-cover"
@@ -1009,6 +1076,28 @@ export default async function Home() {
className="brand-halo left-1/2 top-0 h-72 w-[620px] max-w-full -translate-x-1/2 -translate-y-1/3"
style={{ opacity: 0.6 }}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-blob -left-20 bottom-0 h-72 w-72"
style={{
background:
"color-mix(in srgb, var(--color-accent) 45%, transparent)",
opacity: 0.5,
}}
/>
<div
className="aurora-blob -right-16 top-4 h-64 w-64"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
animationDelay: "-6s",
opacity: 0.5,
}}
/>
</div>
<h2
id="join-cta-title"
className="relative text-3xl font-black tracking-tight text-balance sm:text-4xl"
@@ -1025,34 +1114,24 @@ export default async function Home() {
<div className="relative mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/register"
className="btn-shine group inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wide transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
style={{
background:
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
color: "var(--color-primary-foreground)",
boxShadow:
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
}}
className="btn-brand btn-shine group px-8 py-4 text-sm font-black uppercase tracking-wide"
>
{tp("ctaJoin")}
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
→
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
>
<ArrowRight className="h-4 w-4" />
</span>
</Link>
<Link
href="/community"
className="inline-flex items-center rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 hover:-translate-y-1 hover:brightness-110 active:scale-95"
style={{
color: "#fff",
background: "rgba(255,255,255,0.07)",
border: "1px solid rgba(255,255,255,0.22)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
className="btn-glass-dark px-8 py-4 text-sm font-bold"
>
{tp("browseCommunity")}
</Link>
</div>
<div className="hero-ring" aria-hidden="true" />
</section>
</Reveal>
</div>
+11 -3
View File
@@ -10,6 +10,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
import { publicReadFailure } from "@/lib/services/public-read";
export async function generateMetadata(): Promise<Metadata> {
@@ -53,9 +54,16 @@ export default async function PhotosPage() {
photos = publicReadFailure("photos")(error);
}
// Users can hide their photos everywhere, not only on their profile.
const photoOwners = [...new Set((photos ?? []).map((p) => p.userId))];
const photoPrivacy = await loadProfilePrivacyMap(photoOwners);
const visiblePhotos = (photos ?? []).filter(
(p) => photoPrivacy.get(p.userId)?.photos !== false,
);
// Pre-shape for the client lightbox: translate captions server-side so the
// client component stays free of i18n/db dependencies.
const items: LightboxPhoto[] = (photos ?? []).map((p) => ({
const items: LightboxPhoto[] = visiblePhotos.map((p) => ({
id: String(p.id),
url: p.url,
alt: t("photoAlt", { id: p.userId }),
@@ -64,7 +72,7 @@ export default async function PhotosPage() {
}));
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="📸" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={items.length === 0}>
@@ -76,6 +84,6 @@ export default async function PhotosPage() {
<PhotoLightbox photos={items} />
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -96,7 +96,7 @@ export default async function PollDetailPage({
}
return (
<main className="page-grid">
<section className="page-grid">
<p className="muted" style={{ margin: 0 }}>
<Link href="/polls">{t("back")}</Link>
</p>
@@ -248,6 +248,6 @@ export default async function PollDetailPage({
</div>
</ContentCard>
) : null}
</main>
</section>
);
}
@@ -69,6 +69,8 @@ export function PollVoteForm({
run(() => voteOnPoll({ pollId, votes }), {
successMessage: t("voteSuccess"),
errorMessage: t("voteError"),
// voteOnPoll revalidates /polls and /polls/<id>.
revalidated: true,
});
}
+2 -2
View File
@@ -60,7 +60,7 @@ export default async function PollsPage() {
}));
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="📊" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={polls.length === 0}>
@@ -122,6 +122,6 @@ export default async function PollsPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -65,7 +65,7 @@ export default async function RadioApplyPage({
: null;
return (
<main className="page-grid">
<section className="page-grid">
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
@@ -199,6 +199,6 @@ export default async function RadioApplyPage({
</button>
</form>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -33,7 +33,7 @@ export default async function RadioContestDetailPage({
const active = contest.isActive ? "Active" : "Ended";
return (
<main className="page-grid">
<section className="page-grid">
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/contests">← Back to contests</Link>
</p>
@@ -90,6 +90,6 @@ export default async function RadioContestDetailPage({
</tbody>
</table>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -23,7 +23,7 @@ export default async function RadioContestsPage() {
.catch(() => []);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🎉" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={contests.length === 0}>
@@ -71,6 +71,6 @@ export default async function RadioContestsPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export default async function RadioGiveawayDetailPage({
: null);
return (
<main className="page-grid">
<section className="page-grid">
<p className="muted" style={{ margin: 0 }}>
<Link href="/radio/giveaways">← Back to giveaways</Link>
</p>
@@ -95,6 +95,6 @@ export default async function RadioGiveawayDetailPage({
</tbody>
</table>
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -25,7 +25,7 @@ export default async function RadioGiveawaysPage() {
.catch(() => []);
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🎁" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={giveaways.length === 0}>
@@ -78,6 +78,6 @@ export default async function RadioGiveawaysPage() {
</div>
)}
</ContentCard>
</main>
</section>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export default async function RadioLeaderboardPage() {
const rows = await loadRows();
return (
<main className="page-grid">
<section className="page-grid">
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={rows.length === 0}>
@@ -85,6 +85,6 @@ export default async function RadioLeaderboardPage() {
</table>
)}
</ContentCard>
</main>
</section>
);
}
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more