184 Commits
Author SHA1 Message Date
openhands 01a297884a Voeg Gitea workflows toe
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 7s
2026-07-28 18:07:26 +02:00
openhands 01196b0843 test: trigger geautomatiseerde pm2 deploy 2026-07-28 17:56:33 +02:00
openhands 48bdd6f2e6 ci: add workflows to correct repository path 2026-07-28 17:50:48 +02:00
remco 2d003a2101 chore(deps): update dependency resend to ^6.18.1
CI / check (pull_request) Successful in 51s
CI / check (push) Successful in 58s
Deploy / deploy (push) Successful in 1m35s
Deploy / release (push) Skipped
2026-07-28 13:01:23 +00:00
remco 2f5d2b6e09 chore(deps): update All dependencies
CI / check (pull_request) Successful in 1m6s
CI / check (push) Successful in 36s
Deploy / deploy (push) Successful in 1m37s
Deploy / release (push) Skipped
2026-07-28 10:00:31 +00:00
openhands f31afb5318 chore(tsconfig): bypass baseUrl deprecation warning with ignoreDeprecations
CI / check (push) Successful in 1m18s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m37s
2026-07-27 23:29:52 +02:00
openhands b12eaeef82 security(deps): patch sharp libvips and brace-expansion DoS vulnerabilities
CI / check (push) Failing after 1m11s
Deploy / deploy (push) Failing after 14s
Deploy / release (push) Skipped
2026-07-27 23:27:33 +02:00
openhands c6905d3865 chore(deps): update pnpm-lock.yaml with new overrides for CI validation
CI / check (push) Failing after 35s
Deploy / deploy (push) Failing after 14s
Deploy / release (push) Skipped
2026-07-27 23:25:29 +02:00
openhands 8876b38f0a security(deps): patch CVE-2026-44705 path traversal in tmp dependency
CI / check (push) Failing after 6s
Deploy / deploy (push) Failing after 5s
Deploy / release (push) Skipped
2026-07-27 23:24:37 +02:00
openhands 02280d0f42 fix: execute aliased tsc6 binary in typecheck script
CI / check (push) Failing after 51s
Deploy / deploy (push) Failing after 16s
Deploy / release (push) Skipped
2026-07-27 23:21:26 +02:00
openhands f43bb234ab fix: use pnpm exec for local tsc in typecheck script
CI / check (push) Failing after 15s
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 12s
2026-07-27 23:18:30 +02:00
openhands dacc4cadfd chore(deps): upgrade to typescript 7 bridge and clean up pnpm v11 workspace configs
CI / check (push) Failing after 58s
Deploy / deploy (push) Failing after 11s
Deploy / release (push) Skipped
2026-07-27 23:14:00 +02:00
remco 65f118c918 chore(deps): update All dependencies
CI / check (pull_request) Successful in 48s
CI / check (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m38s
Deploy / release (push) Skipped
2026-07-27 20:00:33 +00:00
openhands cc458332d7 chore: group all dependency updates into a single PR
CI / check (push) Successful in 42s
Deploy / deploy (push) Failing after 1m17s
Deploy / release (push) Skipped
2026-07-27 21:18:52 +02:00
remco 3f43a62243 chore(deps): update dependency eslint to v10.8.0
CI / check (pull_request) Successful in 1m8s
CI / check (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
2026-07-27 19:03:11 +00:00
remco 5f98b949ad chore(deps): Pin dependencies
CI / check (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-27 20:46:27 +02:00
remco cab4280e63 chore(deps): Pin dependencies
CI / check (pull_request) Successful in 1m5s
CI / check (push) Successful in 1m18s
Deploy / deploy (push) Successful in 2m8s
Deploy / release (push) Skipped
2026-07-27 18:46:12 +00:00
openhands 016f35bab3 chore: allow renovate to run at any time
CI / check (push) Successful in 1m0s
Deploy / deploy (push) Successful in 2m8s
Deploy / release (push) Skipped
2026-07-27 20:40:33 +02:00
openhands d97db2a3e7 chore: remove deprecated separateMajorMinor preset
CI / check (push) Successful in 48s
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-27 20:34:09 +02:00
openhands ff47fd31e5 chore: restore original renovate config
CI / check (push) Successful in 1m23s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m4s
2026-07-27 20:15:06 +02:00
openhands fa9b8c83d2 chore: remove deprecated separateMajorMinor preset for testing
CI / check (push) Successful in 46s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m24s
2026-07-27 19:45:13 +02:00
openhands 69506b70e0 chore: fix renovate schedule cron format
CI / check (push) Successful in 52s
Deploy / deploy (push) Successful in 2m10s
Deploy / release (push) Skipped
2026-07-27 19:35:39 +02:00
openhands b897cff901 chore: fix renovate schedule to valid cron expression
CI / check (push) Successful in 45s
Deploy / deploy (push) Successful in 2m15s
Deploy / release (push) Skipped
2026-07-27 19:33:28 +02:00
openhands edc8fc6584 chore: configure renovate to run every hour
CI / check (push) Successful in 46s
Deploy / deploy (push) Successful in 2m6s
Deploy / release (push) Skipped
2026-07-27 18:05:44 +02:00
openhands 7df37728aa fix(deps): update nodemailer to v9 to resolve SSRF vulnerability (GHSA-p6gq-j5cr-w38f)
CI / check (push) Successful in 58s
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
2026-07-27 17:56:53 +02:00
openhands af9f11d934 fix: resolve all Biome lint warnings
CI / check (push) Successful in 40s
Deploy / deploy (push) Successful in 2m1s
Deploy / release (push) Skipped
- Replace 'as any' with proper CurrencyDb type in send-currency test
- Fix noTemplateCurlyInString warnings in deploy-workflow-contract test
2026-07-27 17:33:07 +02:00
openhands 14b9e77c1a fix(ci): add DATABASE_URL for Prisma 7 config
CI / check (push) Successful in 47s
Deploy / deploy (push) Successful in 2m21s
Deploy / release (push) Skipped
2026-07-27 17:11:23 +02:00
openhands 423a33200e chore: improve tooling, linting, testing, and CI
CI / check (push) Failing after 14s
Deploy / deploy (push) Successful in 2m38s
Deploy / release (push) Skipped
- Add LICENSE file (CC BY-NC-SA 4.0)
- Add .nvmrc pinning Node 22
- Add Renovate config with daily schedule and Gitea Actions workflow
- Reduce ESLint max-warnings from 1000 to 50
- Re-enable Biome a11y/security recommended rules
- Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics)
- Improve CI: run on pushes to feat/fix branches, add pnpm audit
- Add Vitest coverage with v8 provider and thresholds
- Add E2E tests (auth, admin, navigation specs)
- Add admin-maintenance server action test
- Install @vitest/coverage-v8
- Ignore coverage/ directory
2026-07-27 17:03:09 +02:00
openhands 0f5c190ab1 Suppress Sentry release warning when no auth token
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-26 21:50:31 +02:00
openhands 4bb908ecf7 Center client toolbar at top by default instead of right side
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-26 21:37:38 +02:00
openhands 7cc88287b8 Fix: set trustHost to true for production auth
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
2026-07-26 21:15:29 +02:00
openhands de95412a6c Add migration for website_login_logs table
Deploy / deploy (push) Successful in 1m53s
Deploy / release (push) Skipped
2026-07-26 20:56:31 +02:00
openhands 46e9f61b35 Add logger.error callback and error page redirect to NextAuth config
Deploy / deploy (push) Successful in 1m51s
Deploy / release (push) Skipped
2026-07-26 20:47:55 +02:00
openhands 17847545dd Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
Deploy / deploy (push) Successful in 1m52s
Deploy / release (push) Skipped
- Remove .prettierrc (dead config, Biome replaces Prettier)
- Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat
- Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit
- Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts
- Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars
- Replace barrel export src/types/index.ts with direct @/types/common imports
- Make trustHost conditional (development only) in auth.ts
- Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations
- Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
2026-07-26 20:28:11 +02:00
openhands b922f6d49f fix: remove auto-hide, toolbar always visible
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
2026-07-26 18:47:04 +02:00
openhands 8c2fe603d1 fix: keep toolbar interactive when hidden (opacity 8% instead of pointer-events-none) so mouse-enter brings it back
Deploy / deploy (push) Successful in 2m2s
Deploy / release (push) Skipped
2026-07-26 18:38:23 +02:00
openhands 1e0dd183b1 feat: premium toolbar with glassmorphism, auto-hide, edge snap, and smooth snap animation
Deploy / deploy (push) Successful in 1m52s
Deploy / release (push) Skipped
2026-07-26 18:17:32 +02:00
openhands ea71a574a4 refactor: useLayoutEffect for no-flash position init, clean localStorage save, hide toolbar until ready
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
2026-07-26 18:11:12 +02:00
openhands b77dbb65f6 fix: cleanup old position system, add proper useRef import
Deploy / deploy (push) Successful in 1m50s
Deploy / release (push) Skipped
2026-07-26 18:04:50 +02:00
openhands 7f7e11adcf feat: freely draggable toolbar with pixel-position saved to localStorage
Deploy / deploy (push) Failing after 32s
Deploy / release (push) Skipped
2026-07-26 18:02:43 +02:00
openhands 86d15d0d92 fix: toolbar now renders outside overflow container, supports 4 corner positions (top-left/right, bottom-left/right)
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
2026-07-26 17:59:04 +02:00
openhands e336ac3c66 feat: configurable toolbar position (site setting + localStorage toggle + URL override)
Deploy / deploy (push) Successful in 1m53s
Deploy / release (push) Skipped
2026-07-26 17:52:24 +02:00
openhands 623cced64e fix: move toolbar to right side
Deploy / deploy (push) Successful in 1m47s
Deploy / release (push) Skipped
2026-07-26 17:46:57 +02:00
openhands e464fac102 fix: render toolbar via portal to body so it stays visible in fullscreen
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
2026-07-26 17:36:00 +02:00
openhands 2a727b2a71 refactor: smaller toolbar icons, remove language and theme switchers from client
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
2026-07-26 17:29:18 +02:00
openhands f072a28aee refactor: remove logo from client toolbar, move language and logout to left group
Deploy / deploy (push) Successful in 1m50s
Deploy / release (push) Skipped
2026-07-26 17:24:46 +02:00
openhands d010344423 feat: add language switcher, theme switcher, logout, and hotel logo to client toolbar
Deploy / deploy (push) Successful in 1m48s
Deploy / release (push) Skipped
2026-07-26 17:17:56 +02:00
openhands 54c155bcef beautify /me page with decorative cards, hover effects, and consistent styling
Deploy / deploy (push) Successful in 1m51s
Deploy / release (push) Skipped
2026-07-26 16:59:41 +02:00
openhands 0cbd4ca858 fix: remove duplicate Enter button from /me page and keep text beside avatar
Deploy / deploy (push) Successful in 2m4s
Deploy / release (push) Skipped
- Remove standalone 'Enter hotel' button below profile header (button inside avatar column already exists)
- Keep text (rank, username, motto) always beside avatar instead of stacking below on mobile
2026-07-26 16:50:06 +02:00
openhands 0869b82f5f fix: extract room quick-entry form into Client Component to fix Server Component event handler error
Deploy / deploy (push) Successful in 1m48s
Deploy / release (push) Skipped
- Move inline form with onSubmit handler from top-header.tsx (Server Component) into new RoomQuickEntry Client Component
- Add badges section to /me page showing equipped badges from users_badges
- Add last website login timestamp from website_login_logs to /me page stats
- Add search bar to top-header for user search
- Add achievement score, respects received, friend count stats to /me page
- Add quick links card to /me page with Messages/Profile/Settings/Shop
- Clean up RoomQuickEntry: use form.elements.namedItem, remove unused ref, add aria-label
2026-07-26 16:43:13 +02:00
openhands 29cd09e458 Revert "fix: remove brede Enter balk van /me pagina"
Deploy / deploy (push) Successful in 1m32s
Deploy / release (push) Skipped
This reverts commit 04743fde6e.
2026-07-26 13:28:35 +02:00
openhands 04743fde6e fix: remove brede Enter balk van /me pagina
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
2026-07-26 13:19:28 +02:00
openhands 3284e5a915 feat: add Toaster, notification bell, search, unread badges, last login, recent rooms, hotel alerts, quick room entry, friend requests in top bar
Deploy / deploy (push) Successful in 1m53s
Deploy / release (push) Skipped
2026-07-26 13:17:51 +02:00
openhands a906d671fb fix: remove green dot from online counter in TopHeader
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-26 13:06:11 +02:00
openhands a847ecae04 fix: restore lange banner (SiteHeader) for logged-in users, keep Enter button in TopHeader
Deploy / deploy (push) Successful in 1m50s
Deploy / release (push) Skipped
2026-07-26 13:03:02 +02:00
openhands 2818d2eb20 fix: remove lange banner (SiteHeader) for logged-in users; add online counter and Enter button in TopHeader
Deploy / deploy (push) Successful in 1m50s
Deploy / release (push) Skipped
2026-07-26 12:59:50 +02:00
openhands 7253bb61be fix: increase avatar sizes for newest citizens on index and register; add Enter button below avatar on /me
Deploy / deploy (push) Successful in 1m51s
Deploy / release (push) Skipped
2026-07-26 12:53:14 +02:00
openhands 4e2c4ccf07 Add sudo chown -R www-data:www-data /var/www/Nitro-V3/ at end of update to fix permissions after build
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
2026-07-25 21:21:45 +02:00
openhands 2c0478bfc0 test: add room, user, ticket validator test suites (83 files, 430+ tests)
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
2026-07-25 18:51:33 +02:00
openhands 960fea3549 test: expand validator, webhook, and catalog-tree test coverage
Deploy / deploy (push) Successful in 2m24s
Deploy / release (push) Skipped
2026-07-25 18:47:04 +02:00
openhands 78b1982145 fix: remove as any cast from catalog-tree test mock
Deploy / deploy (push) Successful in 2m29s
Deploy / release (push) Skipped
2026-07-25 18:36:40 +02:00
openhands ac3c42f308 test: add action-helper, send-currency, permission-ranks, catalog-tree, and audit test suites (77 files, 405 tests)
Deploy / deploy (push) Successful in 2m14s
Deploy / release (push) Skipped
2026-07-25 18:25:33 +02:00
openhands 2e9db75eca test: add audit, staff-activity, and abuse-guard test suites (75 files, 376 tests)
Deploy / deploy (push) Successful in 2m14s
Deploy / release (push) Skipped
2026-07-25 18:10:15 +02:00
openhands 04b9844f0f fix: restore 53 prisma models referenced in code (incorrectly removed in cleanup)
Deploy / deploy (push) Successful in 2m5s
Deploy / release (push) Skipped
2026-07-25 17:55:30 +02:00
openhands c670227ad4 fix: downgrade typescript to 5.x and nodemailer to 8.x for peer dep compatibility + update lockfile
Deploy / deploy (push) Failing after 28s
Deploy / release (push) Skipped
2026-07-25 17:37:23 +02:00
openhands 1acace49d0 refactor: full codebase overhaul — dead code removal, env validation, logger migration, date consolidation, Prisma schema cleanup, button consistency, useEffect deps, test coverage
Deploy / deploy (push) Failing after 8s
Deploy / release (push) Skipped
- env.ts: added 10 missing Zod-validated env vars (imager, paypal currency, argon2/bcrypt params)
- Migrated 6 modules from process.env to validated env.* (auth, proxy-auth, paypal, password, redis, imager, moderation, alert, logger)
- Replaced console.warn/error with pino logger in 9 server-side modules
- Removed 50+ dead exports (SWF wrappers, coalesceHotelName, signIn, isStaff re-export, formatTimestamp, Skeleton/SkeletonCard, 4 unused housekeeping sections)
- Consolidated date formatting: 28 files migrated to shared formatDate() from @/lib/format-date
- Wired 4 radio/settings API routes through cached siteSettings service instead of raw Prisma queries
- Added getMany()/getAll() helpers to SiteSettings service
- Removed 88 dead Prisma model definitions (schema 2763→1846 lines)
- Created admin action-helper.ts with wrapAction() for standardized error handling
- Fixed useEffect dependency arrays in 4 data-heavy components
- Replaced raw btn CSS classes with shadcn Button component across admin pages
- Stripped dead i18n namespaces (common, pages.client) from all 22 translation files
- Removed 2 dead scripts (create-release.sh, check-local-imports.ts)
- Fixed knip.json configuration
- Added 7 new test suites: format-date, paypal, moderation, alert, webhook, action-helper, and fixed password.test.ts for env mocking
- All 358 tests passing across 72 test files
- TypeScript: 0 errors
2026-07-25 17:33:06 +02:00
openhands 12e4a07e48 polish: typewriter SSR fix, Community link label, verify premium cards, loading skeleton
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-24 14:49:07 +02:00
openhands fc42a1c138 docs: update README for v1.0.1 — PM2 deploy, premium features, production-ready
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m50s
2026-07-24 14:37:18 +02:00
openhands fee71fe7dc chore: remove Features and Client/Play sections from homepage
Deploy / release (push) Successful in 8s
Deploy / deploy (push) Skipped
- Remove generic Features section (4 cards) to declutter
- Remove Client/Play CTA section (redundant with hero buttons)
2026-07-24 14:16:02 +02:00
openhands e8ade7afa3 fix(deploy): remove standalone logic, fix chown errors in workspace
Deploy / deploy (push) Successful in 2m10s
Deploy / release (push) Skipped
- Remove standalone static file verification (no longer needed)
- Make chown commands tolerate already-deleted temp files
- Update PM2 fallback to use pnpm start
2026-07-24 14:01:30 +02:00
openhands 3773c6cb29 chore: switch from standalone to normal next start
Deploy / deploy (push) Failing after 2s
Deploy / release (push) Skipped
- Remove output:standalone from next.config.ts
- Remove postbuild standalone copy script
- Change start script from standalone/server.js to next start
- Update PM2 to run pnpm start
2026-07-24 13:57:21 +02:00
openhands c432d4e95d fix: service worker cache busting — unregister old sw on load, bump cache version, stop caching HTML
Deploy / deploy (push) Successful in 1m53s
Deploy / release (push) Skipped
- Bump sw.js cache from v2 to v3
- Unregister all old service workers on page load
- Stop caching HTML/navigation (caused stale CSS refs after rebuild)
2026-07-24 13:50:45 +02:00
openhands b37b6bc2b9 feat: premium polish pass — typing effect, animated counters, ambient orbs, input glow, show/hide password, redesigned settings, Reveal animations
Deploy / deploy (push) Successful in 1m53s
Deploy / release (push) Skipped
- Add TypewriterText component for hero heading animation
- Add AnimatedCounter with scroll-triggered count-up
- Add AmbientOrbs with floating gradient orbs in hero
- Add input-glow focus effect, btn-shine hover effect CSS
- Add show/hide password toggle on login, register, and home login forms
- Redesign settings page with premium gradient cards and icon headers
- Polish /me dashboard with Reveal animations, btn-shine, input-glow
- Add animate-count CSS keyframe
2026-07-24 13:34:46 +02:00
openhands 5fa342018d chore: remove dead code and unused dependencies
Deploy / deploy (push) Successful in 1m51s
Deploy / release (push) Skipped
- Delete 4 unused files detected by knip
- Remove lint-staged, eslint-config-prettier, plausible-tracker
- Clean up knip.json entry patterns
2026-07-24 11:59:50 +02:00
openhands 7f8d083763 Remove Discord and Google OAuth verification from CMS
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
- Remove Discord + Google OAuth providers from NextAuth config
- Remove social login buttons (Discord/Google) from login form
- Delete link-discord.ts action and discord-verify-form.tsx component
- Simplify verify page to email-only verification flow
- Remove connections settings page and its link from settings
- Clean env.ts, .env, .env.example of Discord/Google client vars
- Remove discordUrl social icon from register, login, and homepage
- Clean translation files: remove continueWithDiscord, continueWithGoogle, connections keys
2026-07-24 11:49:16 +02:00
openhands f7f6e09174 Fix migration connection exhaustion and polish auth pages
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m49s
- apply-migrations.ts: use single shared connection instead of one per operation
- Increase MariaDB max_connections from 151 to 300 in server config
- home-login-form.tsx: replace hardcoded gray colors with theme variables
- register-form.tsx: add password strength meter, spinner, theme-aligned inputs
- login-form.tsx: add Discord/Google SVG icons, spinner, smoother 2FA animation
- page/register/login: apply premium animations (float, stagger, glow, gradient)
2026-07-24 11:30:58 +02:00
openhands a9f1f4f99d Fix Turbopack NFT warning
Deploy / deploy (push) Failing after 2m47s
Deploy / release (push) Skipped
- Remove import.meta.dirname from turbopack config (unnecessary filesystem op)
- Add /*turbopackIgnore: true*/ to 3 path.join calls in upload-import.ts
  that were missing the comment, causing Turbopack to trace the whole
  project unintentionally
2026-07-23 20:57:43 +02:00
openhands f6ee99801d Fix migration 'Too many connections' error during deploy
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 2m56s
- pm2 stop now uses --kill-timeout 10000 for graceful shutdown
- Wait 10s after stopping (was 3s) for MariaDB to reclaim connections
- Migration retries increased from 5 to 10 with 5s wait (was 3s)
- Total retry window: ~50s instead of ~15s
2026-07-23 20:04:34 +02:00
openhands 7fc7412f18 Remove experimental Next.js config options
Deploy / deploy (push) Failing after 2m25s
Deploy / release (push) Skipped
staleTimes, optimizePackageImports, and staticGenerationMaxConcurrency
were all experimental-only in Next.js 16. Removed them:

- staleTimes: router cache defaults are sufficient
- optimizePackageImports: Turbopack already tree-shakes lucide-react
- staticGenerationMaxConcurrency: mitigated by DATABASE_POOL_SIZE=5

Eliminates the 'Experiments (use with caution)' warning for our custom
options. Only clientTraceMetadata remains (Next.js framework default).
2026-07-23 19:50:17 +02:00
openhands dae381e07c Persist .next/cache across deploys for faster rebuilds
Deploy / deploy (push) Successful in 2m2s
Deploy / release (push) Skipped
Each deploy creates a fresh worktree and deletes .next, losing the
Turbopack build cache. Now restore .next/cache from the live site
into the stage before pnpm build, so Next.js can do incremental
compilation. After cutover the cache persists in the live .next/ for
the next deploy.
2026-07-23 19:33:12 +02:00
openhands 7dbca4117c Fix BCRYPT_ROUNDS override — use function to read env at call time
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
BCRYPT_ROUNDS was a module-level const evaluated at import time,
so env overrides in tests or CI had no effect. Changed to function
that reads process.env on each call. Also lowered hardcoded
bcryptHash(..., 10) in test to use env var with fallback 4.

CI: BCRYPT_ROUNDS=2. Password test suite: 1860ms → 29ms.
2026-07-23 19:30:47 +02:00
openhands 331a18e9c7 Add BCRYPT_ROUNDS env override, use 4 in CI
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
- Bcrypt rounds now configurable via BCRYPT_ROUNDS env var (default 12)
- CI/deploy use BCRYPT_ROUNDS=4 for faster tests
- Argon2 test: 1161ms → 17ms (already done)
- Password test suite: 1860ms → 1330ms
2026-07-23 19:24:52 +02:00
openhands f7551166c5 Speed up tests: lower argon2 params in CI, env overrides
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m1s
- Allow ARGON2_MEMORY_SIZE, ARGON2_ITERATIONS, ARGON2_PARALLELISM env overrides
- Use m=1024,t=1 in tests (was m=65536,t=4 → ~1s per hash)
- Set fast params in CI and deploy workflows
2026-07-23 19:21:24 +02:00
openhands b6ba554386 Fix deploy error handler: cd to safe dir before pm2
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-23 19:16:34 +02:00
openhands aabf14aaf9 Fix deploy workflow tests for PM2
Deploy / deploy (push) Failing after 33s
Deploy / release (push) Skipped
2026-07-23 19:14:47 +02:00
openhands 9226558aa0 Optimize deploy script
Deploy / deploy (push) Failing after 36s
Deploy / release (push) Skipped
- Zero-downtime PM2 reload instead of stop+start
- Keep .next.prev backup until after health check
- Add standalone static file verification
- Remove duplicate sleep
- Clean up naming
2026-07-23 19:13:06 +02:00
openhands d9e95c823a Update deploy to use PM2, add .next.prev to gitignore
Deploy / deploy (push) Failing after 38s
Deploy / release (push) Skipped
2026-07-23 19:09:05 +02:00
openhands a058a6e923 Rename deploy script to avoid pnpm conflict
Deploy / deploy (push) Failing after 1m55s
Deploy / release (push) Skipped
2026-07-23 18:59:38 +02:00
openhands 5913328a53 Switch to PM2, add deploy script
Deploy / deploy (push) Failing after 2m8s
Deploy / release (push) Skipped
- Replace tmux with PM2 process manager
- Add deploy script (pnpm deploy = build + pm2 reload)
- PM2 auto-starts on boot via systemd (pm2-root.service)
- Remove old atom-nexst.service (direct next start)
2026-07-23 18:50:46 +02:00
openhands 71a3b0a400 Add date and holiday detection to clock
Deploy / deploy (push) Successful in 1m57s
Deploy / release (push) Skipped
2026-07-23 18:37:32 +02:00
openhands eb603b50a9 Add live clock to top bar
Deploy / deploy (push) Successful in 1m59s
Deploy / release (push) Skipped
2026-07-23 18:32:45 +02:00
openhands b9c09f58d3 Use CMS logo in top bar, improve switcher area
Deploy / deploy (push) Successful in 1m51s
Deploy / release (push) Skipped
- Replace lighthouse.png fallback with cms_logo from site settings
- Add glass-morphism container around Discord/Language/Theme switchers
- Apply to index, register, and login pages
2026-07-23 18:25:38 +02:00
openhands 83356b9a9c Polish home, register, and login pages
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
- Add pixel font to headings across all pages
- Improve stats icons (online-friends.png, friends.png)
- Fix features icons (star.gif → navigation/me.png)
- Replace top bar logo with lighthouse.png
- Add read-more arrow overlay on news cards
- Add Nitro client CTA section
- Add Discord link in top bar
- Add view-all link to online users section
- Add Reveal scroll animations to register/login
- Add Habbo gradient headers to register/login sidebars
- Add gradient headers to register/login form boxes
- Switch start script to standalone server
- Add postbuild script to copy static files
2026-07-23 18:17:16 +02:00
openhands a82cf7d2a1 Add top bar with hotel logo, language switcher, and theme toggle
Deploy / deploy (push) Successful in 2m4s
Deploy / release (push) Skipped
2026-07-23 17:06:46 +02:00
openhands 4457117fa9 Premium index page redesign with hubbly icons, Habbo gradient headers, login sidebar, scroll animations
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-23 16:52:08 +02:00
openhands 24c4279a7e Update duckets icon from hubbly.pw
Deploy / deploy (push) Successful in 2m0s
Deploy / release (push) Skipped
2026-07-23 16:46:20 +02:00
openhands 36d9046a8d Revert "Replace PNG icons with Habbo-style SVG pixel art icons across index, nav, and mobile nav"
This reverts commit ac16df98c3.
2026-07-23 16:40:41 +02:00
openhands ac16df98c3 Replace PNG icons with Habbo-style SVG pixel art icons across index, nav, and mobile nav
Deploy / deploy (push) Successful in 1m59s
Deploy / release (push) Skipped
- Create 20 Habbo-style pixel-art SVG icon components (home, user, catalog, star, community, games, enter, news, online, camera, chat, clock, heart, arrow, cog, furni, diamond, credits, duckets, rank)
- Update index page: replace all PNG icon references, add pixel font headings, add Habbo-style gradient section headers
- Update navigation: replace all PNG nav icons with Habbo SVG icons
- Update NavDropdown: accept ReactNode icon instead of string path
- Update mobile-nav: replace PNG brand icon with HabboHome SVG
2026-07-23 16:36:32 +02:00
SimoandCursor 255c09b9fd fix(admin): restore sidebar categories via ACL grant repair
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 21:47:23 +02:00
SimoandCursor ce6e8235cc fix(admin): housekeeping TS returns + clearer permissions UX
Deploy / deploy (push) Successful in 1m50s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 21:39:01 +02:00
SimoandCursor 75cace41ea feat(mod): tickets+team tabs; retire HK writes for live ACL
Deploy / deploy (push) Failing after 30s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 21:35:35 +02:00
SimoandCursor 084cca6ea4 feat(mod): lite /mod panel + clarify ACL vs housekeeping legacy
Deploy / deploy (push) Successful in 1m31s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 21:29:32 +02:00
SimoandCursor a384c9a8bb feat(admin): guilds console + user sanctions timeline
Deploy / deploy (push) Successful in 1m44s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 21:19:16 +02:00
openhands 1e77398a7c Add all missing translation keys to all 22 languages (English baseline)
Deploy / deploy (push) Successful in 2m16s
Deploy / release (push) Skipped
2026-07-22 20:59:05 +02:00
openhands 5be5405bc9 Add missing translations for all 21 languages (home/register/login keys)
Deploy / deploy (push) Successful in 2m16s
Deploy / release (push) Skipped
2026-07-22 20:45:30 +02:00
openhands dfc768c232 Add missing Dutch translations for stats/features keys on index
Deploy / deploy (push) Successful in 1m45s
Deploy / release (push) Skipped
2026-07-22 20:35:17 +02:00
openhands db46453912 Use next_header.jpg as index hero background
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-22 20:27:23 +02:00
openhands 6a16029839 Add missing Dutch translations for register/login/home keys
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
2026-07-22 19:44:34 +02:00
openhands 782271a31b Premium polish: enhanced toolbar, glass cards, Habbo bg panels, animations
Deploy / deploy (push) Successful in 1m48s
Deploy / release (push) Skipped
2026-07-22 19:30:54 +02:00
openhands 7aec61d1a5 Add theme + language toolbar to index, register, login pages
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-22 19:15:34 +02:00
SimoandCursor 025af147cd feat(admin): marketplace console + analytics degraded banners
Deploy / deploy (push) Successful in 1m40s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 19:06:54 +02:00
SimoandCursor f150aea8b9 feat(admin): P1 — clearer tickets labels, pagination, min_staff_rank, analytics errors
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m37s
Co-authored-by: Cursor <[email protected]>
2026-07-22 19:04:45 +02:00
SimoandCursor 75cdfdebe4 fix(admin): P0 integrity — permanent bans, ACL sidebar, rank guards
Deploy / deploy (push) Successful in 1m36s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 19:01:59 +02:00
openhands 11004626c7 Add newest registered users section to index, register, login pages
Deploy / deploy (push) Successful in 1m49s
Deploy / release (push) Skipped
2026-07-22 18:56:46 +02:00
SimoandCursor 8d28739ca6 feat(admin): expand/collapse all sidebar groups with scrollable nav
Deploy / deploy (push) Successful in 2m6s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-22 18:49:49 +02:00
SimoandCursor e00e9ca2dc refactor: centralize hotel name fallback via FALLBACK_HOTEL_NAME
Deploy / deploy (push) Successful in 2m6s
Deploy / release (push) Skipped
Route all user-facing Atom hotel defaults through resolveHotelName (settings then HOTEL_NAME env then brand constant). Exclude Playwright e2e from tsconfig until deps are installed.

Co-authored-by: Cursor <[email protected]>
2026-07-22 18:45:59 +02:00
openhands 6b2819aa20 Hide all top bars for unauthenticated users
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
2026-07-22 18:40:09 +02:00
openhands ffd434485e Hide navigation bar for unauthenticated users
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m56s
2026-07-22 18:39:22 +02:00
openhands c90b6decc8 Hero buttons: Enter → Register, Nitro → Login
Deploy / deploy (push) Successful in 1m38s
Deploy / release (push) Skipped
2026-07-22 18:31:54 +02:00
openhands 94c02d9ee6 Redesign register + login pages: glass cards, Habbo bg hero panels
Deploy / deploy (push) Successful in 2m5s
Deploy / release (push) Skipped
2026-07-22 18:31:10 +02:00
openhands 0520041d1e Redesign homepage: cleaner Habbo layout, glass cards, hero with hotel bg, removed clutter
Deploy / deploy (push) Successful in 1m59s
Deploy / release (push) Skipped
2026-07-22 18:28:12 +02:00
openhands 5c1b35de7e Use Atom as default hotel name fallback on /me page
Deploy / deploy (push) Successful in 2m1s
Deploy / release (push) Skipped
2026-07-22 18:20:18 +02:00
openhands 5e46dc387d Redirect logged-in users from / to /me
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-22 18:14:50 +02:00
openhands 2438f4f7a8 Add Enter hotel button to /me page
Deploy / deploy (push) Successful in 2m21s
Deploy / release (push) Skipped
2026-07-22 18:12:40 +02:00
openhands fb039b725f Fix dropdown z-index by using negative z-index on clouds instead of overriding all children
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m58s
2026-07-22 16:12:21 +02:00
openhands c2de6cab10 Fix dropdowns falling behind site content due to CSS layer priority
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-22 14:56:50 +02:00
openhands 1ffd46713a Redesign login, register, me pages with premium layout and i18n
Deploy / deploy (push) Successful in 2m7s
Deploy / release (push) Skipped
2026-07-22 14:42:01 +02:00
openhands 063ac9ec40 refactor: clean premium design, fix header banner, remove clutter
Deploy / deploy (push) Successful in 1m54s
Deploy / release (push) Skipped
2026-07-22 14:26:36 +02:00
openhands e2dab9d184 feat: premium polish with animations, glassmorphism, staggered reveals
Deploy / deploy (push) Successful in 1m58s
Deploy / release (push) Skipped
2026-07-22 14:18:24 +02:00
openhands 8fa59d9d19 feat: incorporate design elements from habbo.nl, hubbly.pw, leet.ws
Deploy / deploy (push) Successful in 1m47s
Deploy / release (push) Skipped
2026-07-22 14:12:55 +02:00
openhands 5fe01532ca feat: add Habbo sky background and pixel-font logo
Deploy / deploy (push) Successful in 1m48s
Deploy / release (push) Skipped
2026-07-22 14:07:33 +02:00
openhands eb60f1a762 refactor: complete unique redesign of home and register pages
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
2026-07-22 13:58:58 +02:00
openhands fb0687ec71 feat: redesign home and register pages with professional Habbo-style layout
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m56s
2026-07-22 13:52:23 +02:00
openhands b0ad3a128e fix: resolve turbopack NFT warning and disable Sentry telemetry
Deploy / deploy (push) Successful in 1m46s
Deploy / release (push) Skipped
2026-07-21 23:49:23 +02:00
openhands 3608cb50cc fix: disable Next.js telemetry and enable build cache
Deploy / deploy (push) Successful in 1m37s
Deploy / release (push) Skipped
2026-07-21 23:44:26 +02:00
openhands 6985b3e6ea fix: add REDIS_URL to .env and .env.example
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
2026-07-21 23:42:49 +02:00
openhands 5d0983fe41 fix: exclude e2e directory from vitest
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
2026-07-21 23:39:42 +02:00
openhands 077bc9afc0 chore: update pnpm-lock.yaml for new dev dependencies
Deploy / deploy (push) Failing after 34s
Deploy / release (push) Skipped
2026-07-21 23:38:04 +02:00
openhands 57f85f5453 chore: add professional tooling (husky, lint-staged, playwright, lighthouse, eslint)
Deploy / deploy (push) Failing after 6s
Deploy / release (push) Skipped
2026-07-21 23:36:54 +02:00
SimoandCursor 968ca15c27 feat: jwt cache, redis health, help-ticket admin, and write rate limits
Deploy / deploy (push) Successful in 1m33s
Deploy / release (push) Skipped
Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:58:48 +02:00
SimoandCursor 3bb96eb6f3 test: silence expected clone-import rollback warning
Deploy / deploy (push) Successful in 1m52s
Deploy / release (push) Skipped
The rollback case intentionally hits items_base insert failure; mock console.warn so deploy logs stay clean.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:51:22 +02:00
SimoandCursor fa40eebeed fix(deploy): migrate after stop and shrink DB pool
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m52s
Stage migrate hit ER_CON_COUNT_ERROR while live still held the pool. Build in stage with DATABASE_POOL_SIZE=5, run db:migrate only after stopping the service (with retries), and lower the default pool from 40 to 10.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:44:06 +02:00
SimoandCursor 687e1f9fb0 fix(deploy): stage worktree build and short .next cutover
Deploy / deploy (push) Failing after 11s
Deploy / release (push) Skipped
Build install/test/migrate in a detached worktree while the live site keeps serving, then swap .next and node_modules during a brief stop. Drops nuclear rm -rf src and rolls back .next.prev on cutover failure.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:39:45 +02:00
SimoandCursor 77931db775 fix: cap Prisma pool during next build to stop deploy SSG timeouts
Deploy / deploy (push) Successful in 1m28s
Deploy / release (push) Skipped
Next build workers were each opening up to DATABASE_POOL_SIZE connections and exhausting MySQL (pool active=0), hanging sitemap generation. Cap build pool to 5, fail connect faster, limit SSG concurrency, and make sitemap dynamic.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:28:27 +02:00
SimoandCursor 803e8f36c1 feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / deploy (push) Failing after 4m23s
Deploy / release (push) Skipped
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:21:02 +02:00
SimoandCursor ed7db6e048 feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / deploy (push) Successful in 2m55s
Deploy / release (push) Skipped
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:08:33 +02:00
SimoandCursor 2ff08e5127 chore: patch deps, CSP style nonces, otplib 13, and PR CI
Deploy / deploy (push) Successful in 1m35s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-21 20:46:02 +02:00
openhands 87bda8dbef feat: add Magic Hotel style index and register pages
Deploy / deploy (push) Successful in 1m55s
Deploy / release (push) Skipped
2026-07-21 20:42:37 +02:00
SimoandCursor 964c8b5f5a fix: pin TypeScript 5.9 until Next.js supports TS 7
Deploy / deploy (push) Successful in 1m35s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-21 20:39:14 +02:00
SimoandCursor d2120987b0 perf: replace bcryptjs with native bcrypt for password hashing
Deploy / deploy (push) Failing after 58s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-21 20:37:02 +02:00
SimoandCursor 830d252346 chore: upgrade Zod 4, Vitest 4, and TypeScript 7
Deploy / deploy (push) Failing after 52s
Deploy / release (push) Skipped
Bump major tooling stacks and adapt Zod error APIs (issues/flattenError) plus tsconfig paths for TS 7 baseUrl removal.

Co-authored-by: Cursor <[email protected]>
2026-07-21 20:34:29 +02:00
Simo 160c05a511 Merge pull request 'chore: CSP script nonces, deploy health check, dead-code cleanup' (#14) from chore/phase1-csp-deploy-knip into main
Deploy / deploy (push) Successful in 1m32s
Deploy / release (push) Skipped
Reviewed-on: #14
2026-07-21 20:31:05 +02:00
SimoandCursor 9b47668fe9 chore: CSP script nonces, deploy health check, dead-code cleanup
Add per-request CSP nonces (drop script unsafe-inline), post-deploy /api/health gate, bump next-auth to beta.32, and remove unused motion/cache/permission helpers.

Co-authored-by: Cursor <[email protected]>
2026-07-21 20:27:08 +02:00
SimoandCursor c46dadeda4 chore: harden deps, env validation, admin errors, and redis warnings
Deploy / deploy (push) Successful in 1m39s
Deploy / release (push) Skipped
Align nodemailer with Auth.js peers, bump patch deps, validate env on deploy builds, add admin error boundary, and warn when Redis is missing in production.

Co-authored-by: Cursor <[email protected]>
2026-07-21 20:19:05 +02:00
SimoandCursor c865e6f699 chore: upgrade Next.js to 16.2.11 for July security release
Deploy / deploy (push) Successful in 1m27s
Deploy / release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-07-21 20:06:48 +02:00
openhands 827595ef00 fix: mobile responsiveness for all admin pages
Deploy / deploy (push) Successful in 1m25s
Deploy / release (push) Skipped
- DataTable: overflow-x-auto on table wrapper (affects users, rooms, tickets)
- Radio: overflow-x-auto wrappers around all 3 tables (shouts, applications, schedules)
- Catalog items: responsive grids (grid-cols-1 sm:grid-cols-3) in Add/Edit dialogs
- Catalog tabs: TabsList overflow-x-auto for narrow screens
- Import clone: pagination flex-wrap to prevent overlap on small screens
2026-07-21 16:52:58 +02:00
openhands d189bb5af4 fix: mobile responsive improvements
Deploy / deploy (push) Successful in 2m2s
Deploy / release (push) Skipped
- Audit: responsive summary cards (grid-cols-2 mobile, p-3), responsive empty states
- Repair-icons/sync-all: log area horizontal scroll (overflow-x-auto, whitespace-nowrap)
- Upload furni: tighter mobile padding (p-4 sm:p-5)
- Nitro client: compact toolbar on mobile (smaller buttons, tighter gap, top-2/left-2)
2026-07-21 16:47:02 +02:00
openhands 43dacea672 fix: add auth error detection to sync-all client
Deploy / deploy (push) Successful in 1m25s
Deploy / release (push) Skipped
2026-07-21 15:39:38 +02:00
openhands e4b6d5db21 fix: detect auth errors in SSE clients
Deploy / deploy (push) Successful in 2m3s
Deploy / release (push) Skipped
Audit and repair-icons clients now check the Content-Type before reading the
stream. If the server returns JSON (e.g. CSRF/permission error), the error
message is shown as a toast instead of silently consuming the response as
an empty SSE stream.
2026-07-21 15:37:52 +02:00
openhands bebd65c056 fix: refactor audit to SSE streaming, improve error handling
Deploy / deploy (push) Successful in 1m52s
Deploy / release (push) Skipped
- Changed from blocking JSON endpoint to SSE streaming (like sync-all/repair-icons)
- Progress updates during each audit phase with item counts
- Proper error handling with typed AuditEvent for every failure path
- AbortController support for the client
- Shows real-time progress for each check section
2026-07-21 15:33:15 +02:00
openhands 19e4e10b1d feat: add catalog audit page
Deploy / deploy (push) Successful in 1m56s
Deploy / release (push) Skipped
Checks for:
- items_base entries without catalog_items (not purchasable)
- catalog_items referencing non-existent items_base
- Items without .nitro or icon files on disk
- Duplicate classnames
- Items missing from all configured clone sources
2026-07-21 15:24:58 +02:00
openhands 90107c83d5 fix: rewrite repair-icons with proper error handling and progress
Deploy / deploy (push) Successful in 1m38s
Deploy / release (push) Skipped
- Wrap all DB/file operations in try-catch, send error events via SSE
- Report skipped/progress events for items that already have icons
- Add 'started' event with total count so the UI shows real-time progress
- Catch route-level errors and stream them instead of returning JSON
- Use log line content as React key instead of array index
2026-07-21 15:04:43 +02:00
openhands 13cb5f8670 fix: use admin theme CSS variables in upload-furni-client
Deploy / deploy (push) Successful in 1m35s
Deploy / release (push) Skipped
Replace hardcoded border-gray-300 and text-yellow-* classes
with semantic admin theme tokens (border-input, --admin-warning).
2026-07-21 14:56:38 +02:00
openhands 804daeffca feat: add .nitro upload + sync-all + repair-icons features
Deploy / deploy (push) Failing after 32s
Deploy / release (push) Skipped
- Upload .nitro bundles directly with full DB, catalog, and furnidata integration
- Generate SQL migration files on upload (optional)
- Auto-sync missing furniture from all configured clone sources (SSE batch)
- Repair missing icons by extracting from local .nitro or downloading from sources
- All behind ASSETS_IMPORT permission
2026-07-21 14:53:26 +02:00
openhands c9144dc293 fix: improve health check reliability in do_restart
Deploy / deploy (push) Successful in 1m21s
Deploy / release (push) Skipped
- Use nc -z directly instead of fragile bash /dev/tcp
- Fall through to ss and /dev/tcp as additional fallbacks
- Check emulator health immediately before first sleep
- Show emulator journal log on health check failure
- Increment ERRORS count on failure so exit code reflects it
2026-07-21 14:12:49 +02:00
openhands 7fc53396b4 fix: menu links use bare slugs (Gitea auto-prefixes href with user-content-)
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 21:03:02 +02:00
openhands c0975f8a43 fix: scope deploy contract test to deploy job; menu links use Gitea user-content anchors
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 12s
2026-07-20 20:59:37 +02:00
openhands 43a624bbe7 docs: add self-contained setup reference configs (emulator + nitro) and bold-link buttons
Deploy / deploy (push) Failing after 33s
Deploy / release (push) Skipped
2026-07-20 20:54:26 +02:00
openhands c4532dd340 style: use inline style for release buttons (Gitea strips class)
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:47:07 +02:00
openhands 039b46d12b docs: expand release wizard with emulator/Nitro/Catalogus steps + buttons
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:43:43 +02:00
openhands 43ca6dc4b0 fix: add explicit plain-id anchors so the release menu jumps to sections
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:38:50 +02:00
openhands 45b7a1d9ef test: add explicit anchor for System Requirements to verify Gitea keeps id
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:35:38 +02:00
openhands a741e6103d fix: menu anchors use Gitea user-content- prefix so TOC links work
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 13s
2026-07-20 20:31:34 +02:00
openhands 6e27d9c4fe docs: expand release notes with menu, requirements, install wizard, usage
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:25:45 +02:00
openhands e5de0b53d4 fix: single JSON-encode release body so newlines render (no literal \n)
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:21:33 +02:00
openhands cb91fbef6c chore: cleaner release notes (cap changelog, concise initial release)
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:15:44 +02:00
openhands 9617e59747 chore: remove obsolete catalogus pack-trigger proposal
Deploy / deploy (push) Skipped
Deploy / release (push) Successful in 8s
2026-07-20 20:05:42 +02:00
openhands d103316945 refactor: drop pack.yaml; v*-tag release now includes linked repo URLs + commits
Trigger Site Pack / notify (push) Successful in 0s
Deploy / deploy (push) Successful in 1m35s
Deploy / release (push) Skipped
2026-07-20 20:04:45 +02:00
openhands 7ac5ed3155 ci: trigger lightweight pack rebuild
Trigger Site Pack / notify (push) Successful in 1s
Deploy / deploy (push) Successful in 1m42s
Site Pack / pack (push) Successful in 13s
Deploy / release (push) Skipped
2026-07-20 20:02:11 +02:00
openhands a6b708080c refactor: lightweight pack — bundle CMS only, link other repos via MANIFEST (URL+commit) 2026-07-20 20:02:07 +02:00
openhands d675a842e8 ci: trigger pack rebuild (catalogus full)
Trigger Site Pack / notify (push) Successful in 1s
Deploy / deploy (push) Successful in 1m36s
Site Pack / pack (push) Successful in 2m11s
Deploy / release (push) Skipped
2026-07-20 19:50:59 +02:00
openhands a1ba13c1b5 fix: catalogus full clone (no sparse), fix manifest ver/status grep collision 2026-07-20 19:50:53 +02:00
openhands 981f5a5998 ci: trigger pack rebuild (english manifest)
Trigger Site Pack / notify (push) Successful in 1s
Deploy / deploy (push) Successful in 1m27s
Site Pack / pack (push) Successful in 6s
Deploy / release (push) Skipped
2026-07-20 19:46:21 +02:00
openhands e16cf9b28f i18n: manifest + release notes in English 2026-07-20 19:46:19 +02:00
485 changed files with 42674 additions and 11540 deletions

No files matched your search

+11 -10
View File
@@ -4,10 +4,13 @@
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
# Optional pool tuning (defaults shown)
DATABASE_POOL_SIZE=40
DATABASE_POOL_SIZE=10
DATABASE_IDLE_TIMEOUT_MS=300000
DATABASE_CONNECT_TIMEOUT_MS=10000
# Redis for rate limits, site-settings cache, and JWT invalidation
# REDIS_URL=redis://localhost:6379
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
HOTEL_NAME=Atom
APP_URL=http://localhost:3000
@@ -44,13 +47,10 @@ RCON_PORT=3001
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
# Optional OAuth (enabled when both id+secret are set)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Preferred email provider (HTTP API). Used before SMTP when set.
RESEND_API_KEY=
# Optional SMTP (password reset / alert emails)
# Optional SMTP fallback (password reset / alert emails)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
@@ -71,9 +71,10 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
# Optional Redis — enables shared caching for rate limiting and site settings,
# allowing horizontal scaling across multiple instances. Falls back to in-process
# Maps when unset.
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
# JWT session invalidation cache). Without REDIS_URL the app falls back to
# in-process memory: limits reset on restart and do not work across instances.
# Deploy logs a loud warning when this is unset in production.
REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
@@ -1,21 +0,0 @@
name: Trigger Site Pack
on:
push:
branches:
- main
jobs:
notify:
runs-on: shell
steps:
- name: Dispatch pack build to EpicNext-CMS
run: |
set -e
API="${{ gitea.api_url }}"
TOKEN="${{ secrets.GITEA_TOKEN }}"
# Target repo: remco/EpicNext-Cms (the CMS repo that owns the pack workflow)
curl -s -o /dev/null -X POST \
"${API}/repos/remco/EpicNext-Cms/actions/workflow_dispatch" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main","event_type":"build-pack"}'
echo "Dispatched pack build to EpicNext-CMS"
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
pull_request:
branches:
- main
jobs:
check:
runs-on: shell
steps:
- name: Typecheck, lint, and test
run: |
set -e
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
echo "--- CI checks (${WORK}) ---"
git clone --depth 50 \
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
"${WORK}"
cd "${WORK}"
REF="${{ gitea.sha }}"
if [ -z "${REF}" ]; then
echo "ERROR: missing gitea.sha" >&2
exit 1
fi
git fetch --depth 50 origin "${REF}"
git checkout -f "${REF}"
export SKIP_ENV_VALIDATION=1
export ARGON2_MEMORY_SIZE=1024
export ARGON2_ITERATIONS=1
export BCRYPT_ROUNDS=4
pnpm install --frozen-lockfile
pnpm prisma:generate
pnpm biome:lint
pnpm typecheck
pnpm test
echo "--- CI checks passed ---"
+311 -45
View File
@@ -24,17 +24,177 @@ jobs:
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${PREV_TAG}..${VERSION}")"
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
else
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${VERSION}")"
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
fi
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
echo "${CHANGELOG}" > /tmp/changelog.txt
# Pin the other components at their current commits so the release is reproducible.
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
BODY="## EpicNext-CMS ${VERSION}"$'\n\n'"### Changes"$'\n'"${CHANGELOG}"$'\n\n---\n*Automated release from Gitea Actions*'
{
echo "# EpicNext-CMS ${VERSION}"
echo ""
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
echo ""
echo "## Menu"
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
echo "- [System Requirements](#system-requirements)"
echo "- [Installation Wizard](#installation-wizard)"
echo "- [How it is used](#how-it-is-used)"
echo "- [Changes](#changes)"
echo "- [Linked repositories](#linked-repositories)"
echo ""
echo '<a id="what-is-epicnext-cms"></a>'
echo "## What is EpicNext-CMS?"
echo ""
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
echo ""
echo '<a id="system-requirements"></a>'
echo "## System Requirements"
echo ""
echo "What you need to install before running the CMS:"
echo ""
echo "| Component | Version | Notes |"
echo "| --------- | ------- | ----- |"
echo "| Node.js | >= 22 | Required by Next.js 16 |"
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
echo "| Java | 17+ | Only if building the emulator |"
echo "| Maven | 3.9+ | Only if building the emulator |"
echo ""
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
echo ""
echo '<a id="installation-wizard"></a>'
echo "## Installation Wizard"
echo ""
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
echo ""
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
echo ""
echo "### 1. Clone & Install the CMS"
echo '```bash'
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
echo "cd EpicNext-Cms"
echo "pnpm install"
echo '```'
echo ""
echo "### 2. Database Setup"
echo ""
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
echo '```sql'
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
echo '```'
echo ""
echo "### 3. Configure Environment"
echo '```bash'
echo "cp .env.example .env"
echo '```'
echo ""
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
echo ""
echo "### 4. Generate Prisma Client"
echo '```bash'
echo "pnpm prisma:generate"
echo '```'
echo ""
echo "### 5. Run CMS Migrations"
echo '```bash'
echo "pnpm db:migrate"
echo '```'
echo ""
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
echo ""
echo "### 6. Polaris Emulator"
echo ""
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
echo '```bash'
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
echo "cd /var/www/emulator/Emulator"
echo "mvn clean package"
echo '```'
echo ""
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
echo '```bash'
echo "./update-Nitrov3.sh"
echo '```'
echo ""
echo "### 7. Nitro V3 & Renderer"
echo ""
echo "Clone both Nitro repos and build the client:"
echo '```bash'
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
echo '```'
echo ""
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
echo ""
echo "### 8. Catalogus (catalog & gamedata)"
echo ""
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
echo '```bash'
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
echo '```'
echo ""
echo "### 9. Build & Start the CMS"
echo '```bash'
echo "# Development (hot reload)"
echo "pnpm dev"
echo ""
echo "# Production"
echo "pnpm build && pnpm start"
echo '```'
echo ""
echo "Open http://localhost:3000 in your browser."
echo ""
echo "### 10. First Login"
echo ""
echo "1. Register at /register, or log in with an existing emulator account."
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
echo ""
echo '<a id="how-it-is-used"></a>'
echo "## How it is used"
echo ""
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
echo ""
echo '<a id="changes"></a>'
echo "## Changes"
echo '```'
echo "${CHANGELOG}"
echo '```'
echo ""
echo '<a id="linked-repositories"></a>'
echo "## Linked repositories (exact commits)"
echo ""
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
echo ""
echo "| Component | Repository | Commit |"
echo "|-----------|------------|--------|"
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
echo ""
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
echo ""
echo "---"
echo "*Automated release from Gitea Actions*"
} > /tmp/release-body.md
PAYLOAD="$(printf '%s' "$BODY" | jq -Rs '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' --arg v "${VERSION}")"
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
@@ -76,76 +236,182 @@ jobs:
echo "--- Deploying ---"
LIVE="/var/www/atom-nexst"
STAGE=""
CUTOVER_STARTED=0
error_handler() {
cd /var/www/atom-nexst 2>/dev/null || cd / || true
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
sudo systemctl start atom-nexst.service || true
# Roll back the build artifact if cutover already moved .next into place.
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
echo "Rolling back .next to previous artifact..." >&2
rm -rf "${LIVE}/.next" || true
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
fi
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
fi
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
exit 1
}
trap 'error_handler $LINENO' ERR
docker image prune -f
cd /var/www/atom-nexst/
DEPLOY_USER="$(id -un)"
DEPLOY_GROUP="$(id -gn)"
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" /var/www/atom-nexst/
git config --global --add safe.directory /var/www/atom-nexst
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
git config --global --add safe.directory "${LIVE}"
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
echo "Fetching origin/main..."
git fetch origin --prune
git -C "${LIVE}" fetch origin --prune
echo "Clearing sticky git index bits (if any)..."
STICKY_LIST="$(git ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
if [ -n "${STICKY_LIST}" ]; then
echo "${STICKY_LIST}" | while IFS= read -r f; do
[ -n "$f" ] || continue
git update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
done
fi
echo "Hard reset to origin/main..."
git reset --hard origin/main
echo "Nuclear-replacing src/ from HEAD..."
rm -rf src
git checkout -f HEAD -- src
git clean -fd -e .env -e .env.local -e .env.production -e .env*.local
if ! git diff --exit-code HEAD -- src >/dev/null; then
echo "ERROR: src/ still differs from HEAD after nuclear checkout:" >&2
git diff --stat HEAD -- src >&2 || true
exit 1
fi
echo "Verified src/ matches HEAD"
rm -f tsconfig.tsbuildinfo .tsbuildinfo
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
rm -rf .output dist .next/types .next/dev
export APP_VERSION="$(git rev-parse --short HEAD)"
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
echo "APP_VERSION=${APP_VERSION}"
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
fi
cd "${STAGE}"
rm -f tsconfig.tsbuildinfo .tsbuildinfo
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
rm -rf .output dist .next .next/types .next/dev
# Restore build cache from last deploy so Turbopack can do
# incremental compilation (much faster rebuilds).
if [ -d "${LIVE}/.next/cache" ]; then
mkdir -p .next/cache
cp -r "${LIVE}/.next/cache/." .next/cache/
fi
# Stage shares MySQL with the live app + emulator. Keep the stage pool
# tiny so install/test/build cannot exhaust max_connections.
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
pnpm install --frozen-lockfile
pnpm db:migrate
# prisma generate does not need a live DB connection.
pnpm prisma:generate
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
pnpm typecheck
pnpm test
export SKIP_ENV_VALIDATION=1
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
pnpm build
sudo chown -R www-data:www-data /var/www/atom-nexst/
echo "Hard resetting systemd service..."
sudo systemctl stop atom-nexst.service || true
pkill -f 'next-server' || true
sudo systemctl start atom-nexst.service
sleep 2
if ! systemctl is-active --quiet atom-nexst.service; then
echo "ERROR: atom-nexst.service failed to start!" >&2
if [ ! -d "${STAGE}/.next" ]; then
echo "ERROR: stage build produced no .next/" >&2
exit 1
fi
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
CUTOVER_STARTED=1
pm2 stop next --kill-timeout 10000 || true
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
sleep 10
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
# the old process still holds DATABASE_POOL_SIZE connections.
cd "${STAGE}"
MIGRATE_OK=0
for i in $(seq 1 10); do
if pnpm db:migrate; then
MIGRATE_OK=1
break
fi
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
sleep 5
done
if [ "${MIGRATE_OK}" != "1" ]; then
echo "ERROR: db:migrate failed after retries" >&2
exit 1
fi
cd "${LIVE}"
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
git reset --hard origin/main
# Keep env, uploads, and deps we are about to replace from stage.
git clean -fd \
-e .env -e .env.local -e .env.production -e .env*.local \
-e storage -e public/cache -e node_modules -e .next -e .next.prev
if ! git diff --exit-code HEAD -- src >/dev/null; then
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
git diff --stat HEAD -- src >&2 || true
exit 1
fi
echo "Verified live src/ matches HEAD"
# Save current .next as backup before swapping (kept until health check passes).
if [ -d .next ]; then
mv .next .next.prev
fi
mv "${STAGE}/.next" .next
# Use the exact node_modules the stage build resolved against.
rm -rf node_modules
mv "${STAGE}/node_modules" node_modules
# Prisma client is gitignored — regenerate into live src/generated.
pnpm prisma:generate
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
echo "Starting PM2 (zero-downtime reload)..."
pm2 reload next --update-env || pm2 start next --update-env
sleep 3
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
echo "ERROR: PM2 next failed to start!" >&2
pm2 logs next --lines 20 --nostream >&2 || true
exit 1
fi
echo "Waiting for HTTP health check..."
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
HEALTH_OK=0
for i in $(seq 1 15); do
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
if echo "${BODY}" | grep -q '"database":true'; then
echo "Health OK (${HEALTH_URL})"
HEALTH_OK=1
break
fi
echo "Health attempt ${i}/15 failed, retrying..."
sleep 2
done
if [ "${HEALTH_OK}" != "1" ]; then
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
echo "Last body: ${BODY:-<empty>}" >&2
pm2 logs next --lines 40 --nostream >&2 || true
exit 1
fi
echo "Cleaning stage worktree and previous .next backup..."
rm -rf "${LIVE}/.next.prev"
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
STAGE=""
echo "--- Deployed successfully ---"
-356
View File
@@ -1,356 +0,0 @@
name: Site Pack
on:
push:
branches:
- main
repository_dispatch:
types:
- build-pack
schedule:
# Poll all source repos every 15 minutes; rebuild only if a component changed.
- cron: "*/15 * * * *"
workflow_dispatch:
inputs:
force:
description: "Force rebuild even if nothing changed"
required: false
default: "false"
jobs:
pack:
runs-on: shell
env:
GITEA_API: ${{ gitea.api_url }}
GITEA_REPO: ${{ gitea.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
FORCE: ${{ github.event.inputs.force }}
CACHE_DIR: /var/tmp/pack-cache
steps:
- name: Check for upstream changes
id: check
run: |
set -e
exec 2>&1
CMS_REF="$(git -C /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git rev-parse main 2>/dev/null \
|| git ls-remote https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git main 2>/dev/null | awk '{print $1}')"
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
COMBINED="${CMS_REF}:${CAT_REF}:${NITRO_REF}:${RENDER_REF}:${EMU_REF}"
echo "Current refs: $COMBINED"
STATE="$CACHE_DIR/refs.state"
mkdir -p "$CACHE_DIR"
PREV="$(cat "$STATE" 2>/dev/null || echo "")"
CHANGED=""
if [ "${FORCE:-false}" = "true" ]; then
CHANGED="force"
elif [ "$COMBINED" != "$PREV" ]; then
CHANGED="upstream"
fi
if [ -n "$CHANGED" ]; then
echo "Change detected ($CHANGED)"
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "No upstream changes since last pack"
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
echo "$COMBINED" > "$STATE"
# Expose individual refs so later steps can compare per-component.
echo "cms_ref=$CMS_REF" >> "$GITHUB_OUTPUT"
echo "cat_ref=$CAT_REF" >> "$GITHUB_OUTPUT"
echo "nitro_ref=$NITRO_REF" >> "$GITHUB_OUTPUT"
echo "render_ref=$RENDER_REF" >> "$GITHUB_OUTPUT"
echo "emu_ref=$EMU_REF" >> "$GITHUB_OUTPUT"
- name: Skip if unchanged
if: steps.check.outputs.changed != 'true'
run: |
echo "Nothing to rebuild. Exiting."
exit 0
- name: Setup
if: steps.check.outputs.changed == 'true'
run: |
set -e
exec 2>&1
export PACK_VERSION="pack-$(date +%Y%m%d-%H%M)"
echo "=== Building site pack ${PACK_VERSION} ==="
WORK="/tmp/pack-build"
rm -rf "$WORK" && mkdir -p "$WORK/src" "$WORK/out" "$CACHE_DIR"
echo "WORK=$WORK" >> "$GITHUB_ENV"
echo "PACK_VERSION=$PACK_VERSION" >> "$GITHUB_ENV"
# ---- Per-component: pull only if ref changed, reuse cached build otherwise ----
- name: CMS (this repo)
if: steps.check.outputs.changed == 'true'
run: |
set -e
S="$CACHE_DIR/cms"; mkdir -p "$S"
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
if [ "${{ steps.check.outputs.cms_ref }}" != "$(cat "$S/ref" 2>/dev/null)" ]; then
rm -rf "$WORK/src/cms"
git clone --depth 1 "file://$BARE" "$WORK/src/cms" 2>/dev/null \
|| git clone --depth 1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WORK/src/cms" 2>/dev/null \
|| git -C "$WORK/src/cms" pull --ff-only 2>/dev/null
echo "${{ steps.check.outputs.cms_ref }}" > "$S/ref"
rm -rf "$S/snapshot"; cp -r "$WORK/src/cms" "$S/snapshot"
CMS_ST="new"
else
cp -r "$S/snapshot" "$WORK/src/cms"
echo "CMS unchanged — reused cache"
CMS_ST="cached"
fi
echo "cms:$CMS_ST" >> "$WORK/status.txt"
echo "cms: $(git -C "$WORK/src/cms" rev-parse --short HEAD 2>/dev/null)" | tee -a "$WORK/components.txt"
- name: Catalogus (sparse)
if: steps.check.outputs.changed == 'true'
run: |
set -e
S="$CACHE_DIR/catalogus"; mkdir -p "$S"
if [ "${{ steps.check.outputs.cat_ref }}" != "$(cat "$S/ref" 2>/dev/null)" ]; then
rm -rf "$WORK/src/catalogus"
for attempt in 1 2 3; do
if git clone --depth 1 --filter=blob:none --sparse \
https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git "$WORK/src/catalogus" 2>/dev/null; then
break
fi
echo "Catalogus clone attempt $attempt failed, retrying..." >&2
sleep 5
done
git -C "$WORK/src/catalogus" sparse-checkout set . 2>/dev/null || true
echo "${{ steps.check.outputs.cat_ref }}" > "$S/ref"
rm -rf "$S/snapshot"; cp -r "$WORK/src/catalogus" "$S/snapshot"
else
cp -r "$S/snapshot" "$WORK/src/catalogus"
echo "Catalogus unchanged — reused cache"
CAT_ST="cached"
fi
[ -z "$CAT_ST" ] && CAT_ST="new"
echo "catalogus:$CAT_ST" >> "$WORK/status.txt"
echo "catalogus: $(git -C "$WORK/src/catalogus" rev-parse --short HEAD 2>/dev/null)" | tee -a "$WORK/components.txt"
- name: Nitro-Render-V3
if: steps.check.outputs.changed == 'true'
run: |
set -e
S="$CACHE_DIR/render"; mkdir -p "$S"
# Nitro-V3's vite.config expects the renderer at ../Nitro_Render_V3
RENDER_DIR="$WORK/src/Nitro_Render_V3"
if [ "${{ steps.check.outputs.render_ref }}" != "$(cat "$S/ref" 2>/dev/null)" ]; then
rm -rf "$RENDER_DIR"
for attempt in 1 2 3; do
if git clone --depth 1 https://github.com/duckietm/Nitro_Render_V3.git "$RENDER_DIR" 2>/dev/null; then
break
fi
echo "Render clone attempt $attempt failed, retrying..." >&2
sleep 5
done
[ -f "$RENDER_DIR/package.json" ] || { echo "Render clone failed"; exit 1; }
cd "$RENDER_DIR"
yarn install --frozen-lockfile --prefer-offline 2>&1 || yarn install 2>&1
rm -rf "$S/files"; mkdir -p "$S/files"; cp -r . "$S/files/" 2>/dev/null || true
rm -rf "$S/files/node_modules"
echo "${{ steps.check.outputs.render_ref }}" > "$S/ref"
REN_ST="new"
else
echo "Render unchanged — reused cache"
rm -rf "$RENDER_DIR"; mkdir -p "$RENDER_DIR"; cp -r "$S/files/"* "$RENDER_DIR/" 2>/dev/null || true
REN_ST="cached"
fi
[ -z "$REN_ST" ] && REN_ST="new"
echo "render:$REN_ST" >> "$WORK/status.txt"
mkdir -p "$WORK/out/render"; cp -r "$S/files/"* "$WORK/out/render/" 2>/dev/null || true
echo "render: $(git -C "$RENDER_DIR" rev-parse --short HEAD 2>/dev/null || echo cached)" | tee -a "$WORK/components.txt"
- name: Nitro-V3
if: steps.check.outputs.changed == 'true'
run: |
set -e
S="$CACHE_DIR/nitro"; mkdir -p "$S"
if [ "${{ steps.check.outputs.nitro_ref }}" != "$(cat "$S/ref" 2>/dev/null)" ]; then
rm -rf "$WORK/src/nitro"
for attempt in 1 2 3; do
if git clone --depth 1 https://github.com/duckietm/Nitro-V3.git "$WORK/src/nitro" 2>/dev/null; then
break
fi
echo "Nitro clone attempt $attempt failed, retrying..." >&2
sleep 5
done
[ -f "$WORK/src/nitro/package.json" ] || { echo "Nitro clone failed"; exit 1; }
cd "$WORK/src/nitro"
yarn install --frozen-lockfile --prefer-offline 2>&1 || yarn install 2>&1
yarn build
rm -rf "$S/dist"; mkdir -p "$S/dist"; cp -r dist/* "$S/dist/" 2>/dev/null || true
echo "${{ steps.check.outputs.nitro_ref }}" > "$S/ref"
NIT_ST="new"
else
echo "Nitro unchanged — reused cached dist"
NIT_ST="cached"
fi
[ -z "$NIT_ST" ] && NIT_ST="new"
echo "nitro:$NIT_ST" >> "$WORK/status.txt"
mkdir -p "$WORK/out/nitro"; cp -r "$S/dist/"* "$WORK/out/nitro/" 2>/dev/null || true
echo "nitro: $(git -C "$WORK/src/nitro" rev-parse --short HEAD 2>/dev/null || echo cached)" | tee -a "$WORK/components.txt"
- name: Polaris Emulator
if: steps.check.outputs.changed == 'true'
run: |
set -e
S="$CACHE_DIR/emulator"; mkdir -p "$S"
if [ "${{ steps.check.outputs.emu_ref }}" != "$(cat "$S/ref" 2>/dev/null)" ]; then
rm -rf "$WORK/src/emulator"
for attempt in 1 2 3; do
if git clone --depth 1 https://github.com/duckietm/Polaris-Emulator.git "$WORK/src/emulator" 2>/dev/null; then
break
fi
echo "Emulator clone attempt $attempt failed, retrying..." >&2
sleep 5
done
[ -f "$WORK/src/emulator/Emulator/pom.xml" ] || { echo "Emulator clone failed (no pom.xml)"; exit 1; }
cd "$WORK/src/emulator/Emulator"
mvn package -q -T 1C -o -Dmaven.test.skip=true 2>&1 \
|| mvn package -q -T 1C -Dmaven.test.skip=true 2>&1
rm -f "$S"/*.jar; find target -maxdepth 1 -name 'Polaris-*-jar-with-dependencies.jar' -exec cp {} "$S/" \; 2>/dev/null || true
echo "${{ steps.check.outputs.emu_ref }}" > "$S/ref"
EMU_ST="new"
else
echo "Emulator unchanged — reused cached jar"
EMU_ST="cached"
fi
[ -z "$EMU_ST" ] && EMU_ST="new"
echo "emulator:$EMU_ST" >> "$WORK/status.txt"
mkdir -p "$WORK/out/emulator"; cp "$S"/*.jar "$WORK/out/emulator/" 2>/dev/null || true
echo "emulator: $(git -C "$WORK/src/emulator" rev-parse --short HEAD 2>/dev/null || echo cached)" | tee -a "$WORK/components.txt"
- name: Assemble Pack
if: steps.check.outputs.changed == 'true'
run: |
set -e
cd "$WORK/out"
cp -r "$WORK/src/cms" "$WORK/out/cms" 2>/dev/null || true
rm -rf "$WORK/out/cms/.git" "$WORK/out/cms/node_modules" 2>/dev/null || true
cp -r "$WORK/src/catalogus" "$WORK/out/catalogus" 2>/dev/null || true
rm -rf "$WORK/out/catalogus/.git" 2>/dev/null || true
# Diagnostics: show each component size before packing.
for d in cms catalogus nitro render emulator; do
echo "out/$d: $(du -sh "$WORK/out/$d" 2>/dev/null | cut -f1) ($(find "$WORK/out/$d" -type f 2>/dev/null | wc -l) files)"
done
# Hard guard: every component must be present and non-empty.
for d in catalogus nitro render emulator; do
if [ ! -d "$WORK/out/$d" ] || [ -z "$(find "$WORK/out/$d" -type f 2>/dev/null | head -1)" ]; then
echo "ERROR: component '$d' is missing or empty in the pack"; exit 1
fi
done
cp "$WORK/components.txt" "$WORK/out/COMPONENTS.txt"
# Build a clear, human-readable manifest at the pack root.
{
echo "# Site Pack ${PACK_VERSION}"
echo ""
echo "Generated: $(date -u)"
echo ""
echo "## Inhoud (wat zit erin)"
echo ""
echo "| Component | Map | Versie | Status |"
echo "|-----------|-----|--------|--------|"
for pair in "CMS:cms:cms" "Catalogus:catalogus:catalogus" "Nitro-V3:nitro:nitro" "Nitro-Render-V3:render:render" "Polaris Emulator:emulator:emulator"; do
label="${pair%%:*}"; d="${pair#*:}"; key="${pair##*:}"
ver="$(grep "^${key}:" "$WORK/components.txt" 2>/dev/null | head -1 | cut -d: -f2- | tr -d ' ')"
st="$(grep "^${key}:" "$WORK/status.txt" 2>/dev/null | head -1 | cut -d: -f2- | tr -d ' ')"
[ -z "$st" ] && st="new"
echo "| ${label} | ${d}/ | ${ver:-?} | ${st} |"
done
echo ""
echo "## Structuur"
echo '```'
echo "pack/"
echo "├── cms/ # EpicNext-CMS (deze repo)"
echo "├── catalogus/ # Epicnabbo-Catalogus-Updated-Daily"
echo "├── nitro/ # Nitro-V3 client build (dist)"
echo "├── render/ # Nitro-Render-V3"
echo "├── emulator/ # Polaris Emulator jar"
echo "├── MANIFEST.md # dit bestand"
echo "└── COMPONENTS.txt # raw versies"
echo '```'
} > "$WORK/out/MANIFEST.md"
cp "$WORK/out/MANIFEST.md" "$WORK/out/COMPONENTS.txt"
echo "Built at $(date -u)" > "$WORK/out/BUILD_INFO.txt"
cat "$WORK/components.txt" >> "$WORK/out/BUILD_INFO.txt"
PACK_FILE="/tmp/${PACK_VERSION}.tar.gz"
tar -czf "$PACK_FILE" -C "$WORK/out" .
echo "PACK_FILE=$PACK_FILE" >> "$GITHUB_ENV"
echo "Pack created: $(du -h "$PACK_FILE" | cut -f1)"
- name: Build Changelog
if: steps.check.outputs.changed == 'true'
run: |
set -e
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
PREV="$(git -C "$BARE" tag --list 'pack-*' --sort=-creatordate | head -1 || echo '')"
PREV_NOTE="${PREV:-initial}"
{
echo "## Site Pack ${PACK_VERSION}"
echo ""
echo "Volledige pack met alle componenten. Zie het bijgevoegde \`MANIFEST.md\` in de download voor de exacte versies per component."
echo ""
echo "### Wat zit erin (deze pack)"
echo '```'
while IFS= read -r line; do
key="${line%%:*}"; rest="${line#*: }"
st="$(grep "^${key}:" "$WORK/status.txt" 2>/dev/null | head -1 | cut -d: -f2- | tr -d ' ')"
[ -z "$st" ] && st="new"
printf "%-12s %s [%s]\n" "$key" "$rest" "$st"
done < "$WORK/components.txt"
echo '```'
echo ""
echo "### Wat is nieuw sinds pack ${PREV_NOTE}"
for pair in "cms:$WORK/src/cms" "catalogus:$WORK/src/catalogus" "nitro:$WORK/src/nitro" "render:$WORK/src/Nitro_Render_V3" "emulator:$WORK/src/emulator"; do
label="${pair%%:*}"; repo="${pair##*:}"
echo "**${label}**"
echo '```'
git -C "$repo" log --oneline --no-decorated -10 2>/dev/null || echo "n/a"
echo '```'
done
} > /tmp/pack-body.md
cat /tmp/pack-body.md
- name: Create Release + Upload Pack
if: steps.check.outputs.changed == 'true'
run: |
set -e
: "${PACK_FILE:?PACK_FILE not set}"
: "${GITEA_API:?GITEA_API not set}"
: "${GITEA_REPO:?GITEA_REPO not set}"
T="${TOKEN:-${GITEA_TOKEN}}"
[ -n "$T" ] || { echo "No token available"; exit 1; }
# Diagnostics: surface exactly what the runner resolves.
echo "GITEA_API=[${GITEA_API}]"
echo "GITEA_REPO=[${GITEA_REPO}]"
echo "pack-body.md bytes: $(wc -c < /tmp/pack-body.md 2>/dev/null || echo MISSING)"
echo "pack-body.md last bytes (xxd):"; tail -c 40 /tmp/pack-body.md 2>/dev/null | xxd | tail -3
BODY="$(jq -Rs '.' < /tmp/pack-body.md)"
PAYLOAD="$(jq -n --arg tag "${PACK_VERSION}" --arg name "${PACK_VERSION}" --arg body "$BODY" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"
echo "PAYLOAD bytes: ${#PAYLOAD}"
echo "PAYLOAD head:"; echo "$PAYLOAD" | head -c 200; echo
echo "Creating release via ${GITEA_API}/repos/${GITEA_REPO}/releases"
HTTP="$(curl -s -w '%{http_code}' -o /tmp/pack-rel.json \
--retry 3 --retry-delay 3 \
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
-H "Authorization: token ${T}" -H "Content-Type: application/json" -d "$PAYLOAD" || echo 000)"
echo "Create release HTTP: ${HTTP}"
cat /tmp/pack-rel.json | head -c 500; echo
if [ "${HTTP}" = "409" ]; then
REL_ID="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" -H "Authorization: token ${T}" | jq -r ".[] | select(.tag_name==\"${PACK_VERSION}\") | .id" || true)"
HTTP="$(curl -s -w '%{http_code}' -o /tmp/pack-rel.json -X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" -H "Authorization: token ${T}" -H "Content-Type: application/json" -d "$PAYLOAD" || echo 000)"
echo "Patch release HTTP: ${HTTP}"
fi
REL_ID="$(jq -r '.id' /tmp/pack-rel.json 2>/dev/null || echo "")"
[ -n "$REL_ID" ] || { echo "No release ID from response"; exit 1; }
echo "Uploading asset to release ${REL_ID}"
UP="$(curl -s -w '%{http_code}' -o /tmp/pack-asset.json \
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}/assets?name=${PACK_VERSION}.tar.gz" \
-H "Authorization: token ${T}" -H "Content-Type: application/gzip" --data-binary @"${PACK_FILE}" || echo 000)"
echo "Asset upload HTTP: ${UP}"
cat /tmp/pack-asset.json | head -c 300; echo
echo "Release ${PACK_VERSION} created with pack asset"
+28
View File
@@ -0,0 +1,28 @@
name: Renovate
on:
schedule:
- cron: "0 5 * * *" # Every day at 05:00 UTC
workflow_dispatch: # Manual trigger
jobs:
renovate:
runs-on: shell
steps:
- name: Self-hosted Renovate
run: |
set -e
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
mkdir -p /var/tmp/renovate-cache
docker run --rm \
--user "$(id -u):$(id -g)" \
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
-e RENOVATE_AUTODISCOVER=false \
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
-e RENOVATE_ONBOARDING=false \
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
-e LOG_LEVEL=info \
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
ghcr.io/renovatebot/renovate:latest
+4
View File
@@ -1,6 +1,7 @@
node_modules/
.turbo/
.next/
.next.prev/
next-env.d.ts
.env
*.tsbuildinfo
@@ -23,3 +24,6 @@ gitea
# Runtime uploaded media (persistent, outside public/)
storage/
# Test coverage reports
coverage/
+1
View File
@@ -0,0 +1 @@
22
-9
View File
@@ -1,9 +0,0 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 110,
"arrowParens": "always",
"endOfLine": "lf"
}
+438
View File
@@ -0,0 +1,438 @@
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information provided on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
FULLY ALLOWED IN PART OR IN WHOLE, THIS DISCLAIMER MAY NOT APPLY
TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT FULLY ALLOWED IN
PART OR IN WHOLE, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the "Licensor." The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including
without limitation in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
+28 -6
View File
@@ -1,8 +1,8 @@
# EpicNext-CMS
# EpicNext-CMS v1.0.1
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
Features a full public-facing website, an administrative panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio data, smooth page transitions, and extensive extensibility.
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
---
@@ -98,6 +98,27 @@ Open `http://localhost:3000` in your browser.
---
## Production Deployment (PM2)
```bash
pnpm build
pm2 start pnpm --name "next" -- start
pm2 save
```
Restart after updates:
```bash
git pull
pnpm install
pnpm build
pm2 restart next
```
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds.
---
## Scripts
| Command | Description |
@@ -125,13 +146,14 @@ Open `http://localhost:3000` in your browser.
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
| **AnimatePresence** | Framer Motion exit animations on modals, dialogs, and lightbox |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
| **CSS-based Animations** | Base UI components use `data-open`/`data-closed` CSS animations |
| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs |
| **Compression** | Gzip compression enabled on all responses |
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
---
@@ -158,7 +180,7 @@ Open `http://localhost:3000` in your browser.
│ │ ├── cache.ts # In-memory cache fallback
│ │ ├── motion.ts # Framer Motion animation variants
│ │ └── use-event-source.ts # React hook for SSE subscriptions
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it)
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
@@ -228,7 +250,7 @@ Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI
```bash
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite (312+ tests)
pnpm test # Run test suite
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
```
+3 -5
View File
@@ -1,12 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"$schema": "https://biomejs.dev",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
},
"formatter": {
"enabled": true,
@@ -22,9 +23,6 @@
"performance": {
"noImgElement": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
},
"style": {
"noDescendingSpecificity": "off"
}
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";
test.describe("Admin panel", () => {
test("admin login page redirects unauthenticated users", async ({ page }) => {
await page.goto("/admin");
await expect(page).toHaveURL(/login/);
});
test("admin page has login form", async ({ page }) => {
await page.goto("/admin");
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "@playwright/test";
test.describe("Authentication flows", () => {
test("login form validates required fields", async ({ page }) => {
await page.goto("/login");
await page.click('button[type="submit"]');
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
});
test("login with invalid credentials shows error", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="username"]', "nonexistent");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
});
test("register page has password confirmation field", async ({ page }) => {
await page.goto("/register");
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
});
test("register form validates password match", async ({ page }) => {
await page.goto("/register");
await page.fill('input[name="password"]', "Password123!");
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
await page.click('button[type="submit"]');
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
timeout: 5000,
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
test("homepage has title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
});
test("register page loads", async ({ page }) => {
await page.goto("/register");
await expect(page).toHaveTitle(/registreer|register|konto/i);
});
test("login page loads", async ({ page }) => {
await page.goto("/login");
await expect(page).toHaveTitle(/inloggen|login/i);
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
test.describe("Public navigation", () => {
test("homepage loads with expected elements", async ({ page }) => {
await page.goto("/");
await expect(page.locator("nav")).toBeVisible();
await expect(page.locator("footer")).toBeVisible();
});
test("community page loads", async ({ page }) => {
await page.goto("/community");
await expect(page).toHaveTitle(/community/i);
});
test("shop page loads", async ({ page }) => {
await page.goto("/shop");
await expect(page.locator("h1, h2").first()).toBeVisible();
});
test("404 page for unknown routes", async ({ page }) => {
const response = await page.goto("/this-page-does-not-exist");
expect(response?.status()).toBe(404);
});
});
+27 -45
View File
@@ -1,65 +1,47 @@
import js from "@eslint/js";
import nextPlugin from "@next/eslint-plugin-next";
import prettier from "eslint-config-prettier";
import jsxA11y from "eslint-plugin-jsx-a11y";
import reactHooks from "eslint-plugin-react-hooks";
import security from "eslint-plugin-security";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
security.configs.recommended,
prettier,
{
ignores: ["node_modules", ".next", "dist", "build"],
},
js.configs.recommended,
tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
plugins: {
"@next/next": nextPlugin,
"unused-imports": unusedImports,
security: security,
"react-hooks": reactHooks,
"jsx-a11y": jsxA11y,
"@next/next": nextPlugin,
},
rules: {
// Laad automatisch alle aanbevolen beveiligings- en framework-regels in
...security.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-console": "off",
"no-unused-vars": "off",
// Zorg dat variabelen die beginnen met een underscore (_req) genegeerd worden
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-useless-assignment": "off",
"no-undef": "off",
"security/detect-object-injection": "warn",
"security/detect-non-literal-fs-filename": "warn",
},
},
"error",
{
files: ["**/*.test.ts", "**/*.test.tsx", "scripts/**"],
rules: {
"security/detect-object-injection": "off",
"security/detect-non-literal-fs-filename": "off",
"@typescript-eslint/no-explicit-any": "off",
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
},
{
ignores: [
".next/",
"node_modules/",
"src/generated/",
"public/",
"prisma/migrations/",
"db_backup_*.sql",
],
"unused-imports/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"no-console": "warn",
},
},
);
+8 -7
View File
@@ -5,14 +5,15 @@
"src/app/**/layout.{ts,tsx}",
"src/app/**/route.{ts,tsx}",
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
"src/app/**/actions.{ts,tsx}",
"next.config.ts",
"src/middleware.ts"
"sentry.{server,edge}.config.ts"
],
"project": ["src/**/*.{ts,tsx}"],
"ignore": [
"src/**/*.test.{ts,tsx}",
"scripts/**"
"ignoreDependencies": [
"tailwindcss-animate",
"@tailwindcss/forms",
"@tailwindcss/typography",
"pino-pretty",
"tailwindcss"
],
"ignoreDependencies": ["@types/*", "knip"]
"ignoreBinaries": ["sendmail"]
}
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
ci: {
collect: {
url: ["http://localhost:3000"],
numberOfRuns: 3,
},
assert: {
preset: "lighthouse:no-pwa",
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["error", { minScore: 0.9 }],
"categories:best-practices": ["error", { minScore: 0.9 }],
"categories:seo": ["error", { minScore: 0.8 }],
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
},
},
},
};
+13 -30
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
import withBundleAnalyzer from "@next/bundle-analyzer";
import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
@@ -16,25 +16,11 @@ const securityHeaders = [
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
"connect-src 'self' https: wss:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
];
const nextConfig: NextConfig = {
turbopack: { root: import.meta.dirname },
turbopack: {},
serverExternalPackages: [
"@prisma/adapter-mariadb",
"mariadb",
@@ -51,14 +37,8 @@ const nextConfig: NextConfig = {
// Compress responses with gzip
compress: true,
// Cache pages longer in the router cache for faster back/forward navigation
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
optimizePackageImports: ["lucide-react"],
},
// Disable Next.js telemetry
productionBrowserSourceMaps: false,
// Add caching headers for static assets
async headers() {
@@ -99,11 +79,12 @@ const withBA = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
export default withBA(withSentryConfig(config, {
export default withBA(
withSentryConfig(config, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: sentryAuthToken,
silent: !process.env.CI,
silent: !process.env.CI || !sentryAuthToken,
widenClientFileUpload: true,
sourcemaps: {
disable: !sentryAuthToken,
@@ -111,4 +92,6 @@ export default withBA(withSentryConfig(config, {
release: {
create: Boolean(sentryAuthToken),
},
}));
telemetry: false,
}),
);
+61 -31
View File
@@ -11,27 +11,44 @@
"build": "next build",
"start": "next start",
"prisma:generate": "prisma generate",
"typecheck": "tsc --noEmit --incremental false",
"typecheck": "tsc6 --noEmit --incremental false",
"biome:check": "biome check --write .",
"biome:lint": "biome lint .",
"biome:format": "biome format --write .",
"knip": "knip",
"analyze": "ANALYZE=true pnpm build",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
"lhci:collect": "lhci collect",
"lhci:assert": "lhci assert",
"lhci:server": "lhci server",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"jobs:worker": "tsx scripts/jobs-worker.ts"
"jobs:worker": "tsx scripts/jobs-worker.ts",
"prepare": "husky"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"biome check --write",
"eslint --fix --max-warnings=50"
],
"*.{json,md,css,scss,html}": [
"biome format --write"
]
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@sentry/nextjs": "^10.66.0",
"@tanstack/react-virtual": "^3.14.6",
"bcryptjs": "^3.0.2",
"@hookform/resolvers": "^5.5.7",
"@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1",
"@sentry/nextjs": "^10.68.0",
"@tanstack/react-virtual": "^3.14.8",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -43,49 +60,62 @@
"json5": "^2.2.3",
"jszip": "^3.10.1",
"lenis": "^1.3.25",
"lucide-react": "^1.25.0",
"lucide-react": "^1.27.0",
"lzma": "^2.3.2",
"music-metadata": "^11.14.0",
"mysql2": "^3.23.0",
"next": "^16.2.10",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.2",
"mysql2": "^3.23.2",
"next": "^16.2.12",
"next-auth": "5.0.0-beta.32",
"next-intl": "^4.13.4",
"next-view-transitions": "^0.3.5",
"nodemailer": "^9.0.3",
"otplib": "^12.0.1",
"otplib": "^13.4.1",
"pino": "^10.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.81.0",
"resend": "^6.17.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.83.0",
"resend": "^6.18.1",
"sanitize-html": "^2.17.6",
"server-only": "^0.0.1",
"sharp": "^0.35.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.0"
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.4",
"@next/bundle-analyzer": "^16.2.10",
"@biomejs/biome": "2.5.6",
"@eslint/js": "10.0.1",
"@lhci/cli": "^0.15.1",
"@next/bundle-analyzer": "^16.2.12",
"@next/eslint-plugin-next": "^16.2.12",
"@playwright/test": "1.62.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/bcrypt": "^6.0.0",
"@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/sanitize-html": "^2.16.1",
"@vitest/coverage-v8": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^16.4.0",
"knip": "^6.27.0",
"dotenv": "^17.4.2",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-security": "^4.0.1",
"eslint-plugin-unused-imports": "4.4.1",
"husky": "^9.1.7",
"knip": "^6.29.0",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.19",
"prisma": "^7.8.0",
"postcss": "^8.5.24",
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.1",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"vitest": "4.1.10"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
+5249 -1977
View File
File diff suppressed because it is too large. Load diff
+30 -9
View File
@@ -1,14 +1,35 @@
onlyBuiltDependencies:
- esbuild
- prisma
- "@prisma/client"
- "@prisma/engines"
- sharp
- "@parcel/watcher"
- "@swc/core"
- "@sentry/cli"
# pnpm-workspace.yaml
# pnpm v11 vervanger voor onlyBuiltDependencies
allowBuilds:
esbuild: true
prisma: true
"@prisma/client": true
"@prisma/engines": true
sharp: true
"@parcel/watcher": true
"@swc/core": true
"@sentry/cli": true
bcrypt: true
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
overrides:
glob: "^10.4.5"
inflight: "npm:lru-cache@^10.2.2"
rimraf: "^5.0.7"
uuid: "^9.0.1"
fast-uri: "^3.1.3"
"@hono/node-server": "^1.19.13"
postcss: "^8.5.19"
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
tmp: "^0.2.6"
# NIEUWE SECURITY PATCHES:
sharp: "^0.35.3"
brace-expansion: "^5.0.8"
# Zorgt voor een schone terminal zonder de Next-Auth / Nodemailer waarschuwing
peerDependencyRules:
allowedVersions:
nodemailer: "9.0.3"
ignoreMissing:
- nodemailer
@@ -0,0 +1,4 @@
-- CMS JWT invalidation counter on users (stateless NextAuth sessions).
-- Idempotent (MariaDB).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS website_jwt_version INT NOT NULL DEFAULT 0;
@@ -0,0 +1,69 @@
-- Repair admin ACL so ranks that can open the panel also see/use admin sections.
-- Root cause: sidebar filters by ACL; seed only granted some admin.*.view rows,
-- and later sync from housekeeping could leave mid/high ranks incomplete.
-- 1) Every rank that already has admin.dashboard gets ALL admin.* permissions.
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.%'
WHERE EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
JOIN `acl_permissions` apdash ON apdash.id = amp.permission_id
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND apdash.slug = 'admin.dashboard'
)
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp2
WHERE amp2.model_type = 'Role'
AND amp2.model_id = ar.id
AND amp2.permission_id = ap.id
);
-- 2) Safety net matching original seed: ranks >= 6 get all admin.*.view + dashboard.
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN `acl_permissions` ap ON (
ap.slug = 'admin.dashboard'
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
)
WHERE pr.id >= 6
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
-- 3) Ranks >= 7 also get edit/manage/execute tools used by the sidebar.
INSERT INTO `acl_model_permissions` (`model_type`, `model_id`, `permission_id`)
SELECT 'Role', ar.id, ap.id
FROM `permission_ranks` pr
JOIN `acl_roles` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN `acl_permissions` ap ON (
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
OR ap.slug IN (
'admin.permissions.manage',
'admin.rcon.execute',
'admin.assets.import',
'admin.export',
'admin.analytics.export',
'admin.users.ban',
'admin.users.reset_password',
'admin.room.delete'
)
)
WHERE pr.id >= 7
AND NOT EXISTS (
SELECT 1
FROM `acl_model_permissions` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS `website_login_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`ip` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(512) DEFAULT NULL,
`created_at` TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `website_login_logs_user_id_index` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+613 -917
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+6 -12
View File
@@ -1,5 +1,5 @@
const CACHE = "atom-v2";
const API_CACHE = "atom-api-v2";
const CACHE = "atom-v3";
const API_CACHE = "atom-api-v3";
self.addEventListener("install", () => self.skipWaiting());
@@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => {
.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)),
keys
.filter((k) => k !== CACHE && k !== API_CACHE)
.map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()),
@@ -60,14 +62,6 @@ self.addEventListener("fetch", (event) => {
}
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
return res;
})
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
);
event.respondWith(fetch(req));
}
});
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:disableTypesNodeMajor"],
"labels": ["dependencies"],
"rangeStrategy": "bump",
"dependencyDashboard": true,
"dependencyDashboardTitle": "Dependency Dashboard",
"commitMessagePrefix": "chore(deps):",
"commitMessageAction": "update",
"schedule": ["at any time"],
"timezone": "Europe/Amsterdam",
"rebaseWhen": "conflicted",
"prCreation": "immediate",
"prConcurrentLimit": 5,
"packageRules": [
{
"description": "Group all dependency updates into a single PR",
"matchPackagePatterns": ["*"],
"groupName": "All dependencies",
"groupSlug": "all",
"automerge": true
},
{
"description": "Major updates need manual review",
"matchUpdateTypes": ["major"],
"labels": ["dependencies", "major"],
"automerge": false,
"assignees": [],
"reviewers": []
},
{
"description": "Disable updates for engine pins",
"matchPackageNames": ["node", "pnpm"],
"enabled": false
},
{
"description": "Disable server-only (abandoned, pinned at 0.0.1)",
"matchPackageNames": ["server-only"],
"enabled": false
}
]
}
+27 -27
View File
@@ -24,11 +24,16 @@ function getDbConfig(): { url: string; database: string } {
return { url: mysqlConnectionUrl(url), database: dbName };
}
async function ensureConnection(): Promise<void> {
async function openConnection() {
const { url } = getDbConfig();
const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url);
try {
return mysql.createConnection(url);
}
void openConnection;
async function createTrackingTable(
conn: import("mysql2/promise").Connection,
): Promise<void> {
await conn.execute(
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -36,15 +41,11 @@ async function ensureConnection(): Promise<void> {
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
);
} finally {
await conn.end();
}
}
async function getApplied(): Promise<Set<string>> {
const { url } = getDbConfig();
const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url);
async function getApplied(
conn: import("mysql2/promise").Connection,
): Promise<Set<string>> {
try {
const [rows] = await conn.execute(
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
@@ -52,8 +53,6 @@ async function getApplied(): Promise<Set<string>> {
return new Set((rows as { migration: string }[]).map((r) => r.migration));
} catch {
return new Set();
} finally {
await conn.end();
}
}
@@ -70,11 +69,10 @@ function loadMigrations(): MigrationFile[] {
});
}
async function apply(migration: MigrationFile): Promise<void> {
const { url } = getDbConfig();
const mysql = await import("mysql2/promise");
const conn = await mysql.createConnection(url);
try {
async function apply(
conn: import("mysql2/promise").Connection,
migration: MigrationFile,
): Promise<void> {
const statements = splitSqlStatements(migration.sql);
for (const stmt of statements) {
@@ -86,17 +84,18 @@ async function apply(migration: MigrationFile): Promise<void> {
[migration.id],
);
console.log(`[migrate] Applied: ${migration.name}`);
} finally {
await conn.end();
}
}
async function main() {
const flag = process.argv[2];
const mysql = await import("mysql2/promise");
const { url } = getDbConfig();
const conn = await mysql.createConnection(url);
if (flag === "--status") {
await ensureConnection();
const applied = await getApplied();
try {
await createTrackingTable(conn);
const applied = await getApplied(conn);
if (process.argv[2] === "--status") {
const all = loadMigrations();
console.log("\nMigration status:\n");
@@ -114,8 +113,6 @@ async function main() {
return;
}
await ensureConnection();
const applied = await getApplied();
const pending = loadMigrations().filter((m) => !applied.has(m.id));
if (pending.length === 0) {
@@ -126,13 +123,16 @@ async function main() {
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
for (const m of pending) {
try {
await apply(m);
await apply(conn, m);
} catch (err) {
console.error(`[migrate] FAILED: ${m.name}`, err);
process.exit(1);
}
}
console.log("\n[migrate] Done.");
} finally {
await conn.end();
}
}
main().catch((err) => {
-13
View File
@@ -1,13 +0,0 @@
import { findMissingLocalImports } from "../src/lib/local-imports";
const missing = await findMissingLocalImports(process.cwd());
if (missing.length === 0) {
console.log("No unresolved local imports.");
process.exit(0);
}
for (const item of missing)
console.error(`${item.importer}: ${item.specifier}`);
console.error(`${missing.length} unresolved local import(s).`);
process.exitCode = 1;
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
GITEA_API="${GITEA_API:-https://gitlab.epicnabbo.nl}"
REPO="remco/EpicNext-Cms"
TOKEN="${GITEA_TOKEN:-}"
if [ $# -ne 1 ]; then
echo "Usage: $0 <tag>"
exit 1
fi
VERSION="$1"
if [ -z "$TOKEN" ]; then
echo "ERROR: GITEA_TOKEN not set"
exit 1
fi
echo "=== Creating release for ${VERSION} ==="
if ! git -C "$BARE" rev-parse "$VERSION" >/dev/null 2>&1; then
echo "ERROR: tag $VERSION not found in bare repo"
exit 1
fi
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${PREV_TAG}..${VERSION}")"
else
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${VERSION}")"
fi
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
echo "Previous tag: ${PREV_TAG:-none}"
echo "Changelog (${#CHANGELOG} chars):"
echo "${CHANGELOG}"
BODY="## EpicNext-CMS ${VERSION}
### Changes
${CHANGELOG}
---
*Automated release from Gitea Actions*
"
echo "$BODY" | jq -Rs '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' --arg v "${VERSION}" > /tmp/payload.json
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
-X POST "${GITEA_API}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @/tmp/payload.json)"
if [ "${HTTP_CODE}" = "409" ]; then
echo "Release exists, updating..."
RELEASES="$(curl -sf "${GITEA_API}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}")"
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
echo "Release ID: ${REL_ID}"
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
-X PATCH "${GITEA_API}/api/v1/repos/${REPO}/releases/${REL_ID}" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @/tmp/payload.json)"
fi
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
echo "SUCCESS: Release ${VERSION} created/updated"
cat /tmp/release-resp.json | jq -r '.html_url // .id'
else
echo "FAILED HTTP ${HTTP_CODE}"
cat /tmp/release-resp.json
exit 1
fi
+66
View File
@@ -0,0 +1,66 @@
# ===========================================================================
# Polaris Emulator — reference config.ini
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
# values marked CHANGE_ME. The emulator reads this file on startup.
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
# ===========================================================================
# ---- Database configuration ----
db.hostname=127.0.0.1
db.port=3306
db.database=habbo # DB name (shared with the CMS)
db.username=root # Username
db.password=CHANGE_ME # Password
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
db.pool.minsize=25
db.pool.maxsize=100
db.pool.connection_timeout_ms=10000
db.pool.idle_timeout_ms=600000
db.pool.max_lifetime_ms=1800000
db.pool.validation_timeout_ms=5000
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
# ---- Game configuration ----
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
game.host=0.0.0.0
game.port=3000
# ---- RCON configuration ----
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
rcon.host=127.0.0.1
rcon.port=3001
rcon.allowed=127.0.0.1;127.0.0.2
# ---- Nitro secure runtime assets (disabled by default) ----
nitro.secure.assets.enabled=false
nitro.secure.api.enabled=false
nitro.secure.session_ttl_sec=900
nitro.secure.config.root=
nitro.secure.gamedata.root=
# Set a persistent secret when using Cloudflare or multiple backend requests.
nitro.secure.master_key=change-me-to-a-long-random-secret
# ---- Login ----
login.remember.enabled=true
login.remember.duration.days=30
# Optional: set a persistent remember-me JWT secret here.
login.remember.jwt.secret=
login.news.limit=5
# ---- Secure runtime ECDH session TTL in seconds ----
# (kept for compatibility; unused when secure assets are disabled)
# ---- WebSockets (Nitro client) ----
ws.enabled=true
ws.host=0.0.0.0
ws.port=2096
# Header used to obtain the real client IP when behind a proxy
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
ws.ip.header=X-Forwarded-For
# ---- Console / emulator_settings ----
# Run these SQL statements ONCE, after importing the emulator database:
# USE habbo;
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
# console.mode MUST be 0 and the whitelist MUST match your domain.
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Launcher for the Polaris emulator.
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
# Update the JAR name to match the version built by `mvn clean package`.
file_name_emulator=emulator.log
current_time=$(date "+%H%M_%d-%m-%Y")
file_name=$file_name_emulator.$current_time
# Rotate the previous log
mkdir -p /var/log/emu
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
java -Dfile.encoding=UTF8 -Xmx4096m \
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
>/var/log/emu/emulator.log
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=Habbo Emulator
# Make sure the database has started before the emulator can start
After=mariadb.service
Requires=mariadb.service
# If you want to run as a less privileged account, change User below.
# Make sure that account has the right permissions on the working directory and target folders.
[Service]
User=root
# Working directory where config.ini and the JAR live
WorkingDirectory=/var/www/emulator/Emulator/target
# The launcher script we created below. It is a shell wrapper that calls the JAR.
ExecStart=/var/www/emulator/Emulator/target/emulator
SuccessExitStatus=143
TimeoutStopSec=10
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+8
View File
@@ -0,0 +1,8 @@
{
"distObfuscationEnabled": false,
"secureAssetsEnabled": false,
"secureApiEnabled": false,
"apiBaseUrl": "https://MY_DOMAIN:2096",
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
}
+75
View File
@@ -0,0 +1,75 @@
{
"socket.url": "wss://MY_DOMAIN.COM:2096",
"crypto.ws.enabled": false,
"crypto.ws.signing.enabled": false,
"crypto.ws.signing.public_key": "",
"api.url": "https://MY_DOMAIN.COM:2096",
"asset.url": "https://MY_DOMAIN.COM/gamedata",
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
"hof.furni.url": "https://MY_DOMAIN.COM",
"images.url": "${asset.url}/images",
"gamedata.url": "${asset.url}",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [
"${gamedata.url}/config/ExternalTexts.json?v=1",
"${gamedata.url}/config/UITexts.json?v=1"
],
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
"badge.asset.group.external.url": "",
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"room.color.skip.transition": false,
"enable.avatar.arrow": false,
"system.animation.fps": 60,
"system.limits.fps": false,
"system.dispatcher.log": false,
"system.packet.log": false,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"user.badges.group.slot.enabled": true,
"timezone.settings": "Europe/Amsterdam",
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.health.endpoint": "${api.url}/api/auth/health",
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
"login.health.method": "GET",
"login.news.url": "${asset.url}/news/news.json",
"login.turnstile.enabled": false,
"login.turnstile.sitekey": "",
"avatar.mandatory.libraries": ["bd:1", "li:0"],
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
}
+38
View File
@@ -0,0 +1,38 @@
{
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
"achievements.images.url": "${image.library.url}Quests/%image%.png",
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
"url.prefix": "",
"habbopages.url": "/gamedata/habbopages/",
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
"guide.help.alpha.groupid": 0,
"chat.viewer.height.percentage": 0.4,
"widget.dimmer.colorwheel": false,
"avatar.wardrobe.max.slots": 10,
"user.badges.max.slots": 5,
"camera.publish.disabled": false,
"hc.disabled": false,
"badge.descriptions.enabled": true,
"motto.max.length": 38,
"bot.name.max.length": 15,
"wired.action.bot.talk.to.avatar.max.length": 64,
"wired.action.bot.talk.max.length": 64,
"wired.action.chat.max.length": 100,
"wired.action.kick.from.room.max.length": 100,
"wired.action.mute.user.max.length": 100,
"game.center.enabled": false,
"catalog.style.new": true,
"show.google.ads": false,
"loginview": {
"images": {
"background": "${asset.url}/c_images/reception/stretch_blue.png",
"background.colour": "#6eadc8",
"sun": "${asset.url}/c_images/reception/sun.png",
"drape": "${asset.url}/c_images/reception/drape.png",
"left": "${asset.url}/c_images/reception/ts.png",
"right": "${asset.url}/c_images/reception/US_right.png",
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
}
}
}
+51 -8
View File
@@ -2,10 +2,14 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for website advertisements (website_ads). Emulator does not own this
@@ -31,8 +35,11 @@ export async function createAd(formData: FormData): Promise<void> {
targetType: "website_ad",
targetId: Number(ad.id),
});
} catch {
// DB error — page re-renders unchanged.
} catch (err) {
logger.error("Action failed: createAd", {
action: "createAd",
error: err instanceof Error ? err.message : "DB error",
});
revalidatePath("/admin/ads");
return;
}
@@ -62,15 +69,47 @@ export async function updateAd(formData: FormData): Promise<void> {
targetType: "website_ad",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
} catch (err) {
logger.error("Action failed: updateAd", {
action: "updateAd",
id: Number(id),
error: err instanceof Error ? err.message : "DB error",
});
revalidatePath(`/admin/ads/${id}`);
return;
}
redirect("/admin/ads");
}
export async function deleteAd(formData: FormData): Promise<void> {
const deleteAdInput = z.object({
id: z
.union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v))),
});
export const deleteAd = adminAction(
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
async (ctx) => {
const id = ctx.data.id;
try {
await prisma.websiteAds.delete({ where: { id } });
} catch {
throw new ActionError("Advertisement not found");
}
await logStaffActivity({
staffId: Number(ctx.session.user.id),
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
revalidatePath("/admin/ads");
return actionOk();
},
);
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
export async function deleteAdForm(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
@@ -84,8 +123,12 @@ export async function deleteAd(formData: FormData): Promise<void> {
targetType: "website_ad",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
} catch (err) {
logger.error("Action failed: deleteAdForm", {
action: "deleteAdForm",
id: Number(id),
error: err instanceof Error ? err.message : "DB error",
});
}
redirect("/admin/ads");
}
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function dismissApplication(formData: FormData): Promise<void> {
+2 -4
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
async function uniqueSlug(title: string): Promise<string> {
@@ -74,9 +74,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, 255),
...(rawSlug
? { slug: await uniqueSlug(rawSlug) }
: {}),
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC")
.trim()
+2 -4
View File
@@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
"machine",
"super",
]);
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const userId = Number(formData.get("userId"));
@@ -30,8 +28,8 @@ export async function createBan(formData: FormData): Promise<void> {
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const now = Math.floor(Date.now() / 1000);
const banExpire =
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const user = await prisma.user.findUnique({
where: { id: userId },
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const guild = await prisma.guilds.findUnique({
where: { id },
select: { id: true, name: true, userId: true },
});
if (!guild) return;
await prisma.$transaction(async (tx) => {
const threads = await tx.guildsForumsThreads.findMany({
where: { guildId: id },
select: { id: true },
});
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx.guildsForumsComments.deleteMany({
where: { threadId: { in: threadIds } },
});
await tx.guildsForumsThreads.deleteMany({ where: { guildId: id } });
}
await tx.guildForumViews.deleteMany({ where: { guildId: id } });
await tx.guildsMembers.deleteMany({ where: { guildId: id } });
await tx.rooms.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.items.updateMany({ where: { guildId: id }, data: { guildId: 0 } });
await tx.guilds.delete({ where: { id } });
});
await logStaffActivity({
staffId: staff.id,
action: "guild_disband",
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds");
}
+136
View File
@@ -0,0 +1,136 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
const ticketIdField = z
.union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v)));
const replyHelpCenterTicketSchema = z.object({
ticketId: ticketIdField,
content: z.string().min(1).max(5000),
});
const helpCenterTicketIdSchema = z.object({
ticketId: ticketIdField,
});
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
const id = String(ticketId);
revalidatePath("/admin/help-tickets");
revalidatePath(`/admin/help-tickets/${id}`);
revalidatePath("/help/tickets");
revalidatePath(`/help/tickets/${id}`);
}
export const replyHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await prisma.$transaction([
prisma.websiteHelpCenterTicketReplies.create({
data: {
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
},
}),
prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { updatedAt: now },
}),
]);
logAudit({
userId: staffId,
action: "help_center_ticket_reply",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
});
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
},
);
export const closeHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_close",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: true },
after: { open: false },
});
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
},
);
export const reopenHelpCenterTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, open: true },
});
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: true, updatedAt: now },
});
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_reopen",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: false },
after: { open: true },
});
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
},
);
+9 -5
View File
@@ -3,9 +3,9 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -26,8 +26,10 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
const imageUrl = sanitizeField(formData.get("imageUrl"));
const buttonText = sanitizeField(formData.get("buttonText"));
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
const buttonColor =
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor =
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
const entry = await prisma.websiteHelpCenterCategories.create({
@@ -73,8 +75,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
const imageUrl = sanitizeField(formData.get("imageUrl"));
const buttonText = sanitizeField(formData.get("buttonText"));
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
const buttonColor =
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor =
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
await prisma.websiteHelpCenterCategories.update({
+22 -854
View File
@@ -1,194 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
import { redirectSafe } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit";
function sanitize(str: string, max = 255): string {
return String(str ?? "")
.normalize("NFC")
.trim()
.slice(0, max);
}
async function currentUserId(): Promise<number | null> {
const session = await auth();
return session?.user ? sessionUserId(session.user.id) : null;
}
export async function upsertPermission(formData: FormData): Promise<void> {
/**
* Housekeeping table writes are retired. Runtime access uses ACL only.
* Redirect editors to live Permissions.
*/
async function rejectLegacyHousekeepingWrite(): Promise<never> {
await requirePermission(PERMS.SETTINGS_EDIT);
const permission = sanitize(String(formData.get("permission") ?? ""));
const minRank = Number(formData.get("minRank"));
const descriptionRaw = sanitize(String(formData.get("description") ?? ""));
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
const groupName =
sanitize(String(formData.get("groupName") ?? ""), 100) || null;
const dependsOn = sanitize(String(formData.get("dependsOn") ?? "")) || null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
const userId = await currentUserId();
try {
const existing = await prisma.websiteHousekeepingPermissions.findUnique({
where: { permission },
});
if (existing) {
const before = {
minRank: existing.minRank,
description: existing.description,
groupName: existing.groupName,
dependsOn: existing.dependsOn,
};
await prisma.websiteHousekeepingPermissions.update({
where: { permission },
data: {
minRank,
description,
groupName,
dependsOn,
updatedBy: userId ?? undefined,
},
});
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.update",
target: "WebsiteHousekeepingPermissions",
targetId: Number(existing.id),
before,
after: { minRank, description, groupName, dependsOn },
});
} else {
await prisma.websiteHousekeepingPermissions.create({
data: {
permission,
minRank,
description,
groupName,
dependsOn,
updatedBy: userId ?? undefined,
},
});
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.create",
target: "WebsiteHousekeepingPermissions",
targetId: undefined,
after: { permission, minRank, description, groupName, dependsOn },
});
}
} catch {
// Swallow
}
revalidatePath("/admin/housekeeping");
return redirectSafe(
"/admin/permissions?from=housekeeping",
"/admin/permissions",
);
}
export async function deletePermission(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
export async function upsertPermission(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return;
const userId = await currentUserId();
try {
const existing = await prisma.websiteHousekeepingPermissions.findUnique({
where: { id: BigInt(raw) },
});
if (existing) {
await prisma.websiteHousekeepingPermissions.delete({
where: { id: BigInt(raw) },
});
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.delete",
target: "WebsiteHousekeepingPermissions",
targetId: Number(existing.id),
before: {
permission: existing.permission,
minRank: existing.minRank,
description: existing.description,
groupName: existing.groupName,
},
});
}
} catch {
// Already gone / invalid id.
}
revalidatePath("/admin/housekeeping");
export async function deletePermission(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function bulkImportPermissions(
formData: FormData,
_formData: FormData,
): Promise<{ count: number; errors: string[] }> {
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = sanitize(String(formData.get("json") ?? ""));
if (!raw) return { count: 0, errors: ["Empty input"] };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { count: 0, errors: ["Invalid JSON"] };
}
if (!Array.isArray(parsed)) {
return { count: 0, errors: ["Expected an array"] };
}
const userId = await currentUserId();
const errors: string[] = [];
let count = 0;
for (const item of parsed) {
const permission = sanitize(String(item.permission ?? ""));
const minRank = Number(item.minRank);
const description = sanitize(String(item.description ?? "")) || null;
const groupName = sanitize(String(item.groupName ?? ""), 100) || null;
const dependsOn = sanitize(String(item.dependsOn ?? "")) || null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) {
errors.push(`Invalid entry: ${JSON.stringify(item)}`);
continue;
}
try {
await prisma.websiteHousekeepingPermissions.upsert({
where: { permission },
update: {
minRank,
description,
groupName,
dependsOn,
updatedBy: userId ?? undefined,
},
create: {
permission,
minRank,
description,
groupName,
dependsOn,
updatedBy: userId ?? undefined,
},
});
count++;
} catch {
errors.push(`Failed to upsert: ${permission}`);
}
}
revalidatePath("/admin/housekeeping");
return { count, errors };
return rejectLegacyHousekeepingWrite();
}
export async function exportPermissions(): Promise<string> {
@@ -208,688 +48,16 @@ export async function exportPermissions(): Promise<string> {
return JSON.stringify(perms, null, 2);
}
export async function applyPreset(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const preset = String(formData.get("preset") ?? "").trim();
const userId = await currentUserId();
const presets: Record<
string,
Array<{
permission: string;
minRank: number;
description?: string;
groupName?: string;
}>
> = {
moderator: [
{
permission: "admin.dashboard",
minRank: 5,
description: "Access admin dashboard",
groupName: "Core",
},
{
permission: "admin.users.view",
minRank: 5,
description: "View user profiles",
groupName: "Users",
},
{
permission: "admin.users.ban",
minRank: 5,
description: "Ban users",
groupName: "Users",
},
{
permission: "admin.room.view",
minRank: 5,
description: "View rooms",
groupName: "Rooms",
},
{
permission: "admin.bans.view",
minRank: 5,
description: "View bans",
groupName: "Bans",
},
{
permission: "admin.logs.view",
minRank: 5,
description: "View logs",
groupName: "Logs",
},
{
permission: "admin.wordfilter.view",
minRank: 5,
description: "View wordfilter",
groupName: "Wordfilter",
},
{
permission: "mod.dashboard",
minRank: 5,
description: "Mod dashboard",
groupName: "Moderation",
},
{
permission: "mod.cfh.view",
minRank: 5,
description: "View CFH",
groupName: "Moderation",
},
{
permission: "mod.cfh.edit",
minRank: 5,
description: "Handle CFH",
groupName: "Moderation",
},
{
permission: "mod.actions",
minRank: 5,
description: "Mod actions",
groupName: "Moderation",
},
{
permission: "mod.users.view",
minRank: 5,
description: "View users (mod)",
groupName: "Moderation",
},
{
permission: "mod.bans.view",
minRank: 5,
description: "View bans (mod)",
groupName: "Moderation",
},
],
admin: [
{
permission: "admin.dashboard",
minRank: 7,
description: "Access admin dashboard",
groupName: "Core",
},
{
permission: "admin.users.view",
minRank: 7,
description: "View user profiles",
groupName: "Users",
},
{
permission: "admin.users.edit",
minRank: 7,
description: "Edit users",
groupName: "Users",
},
{
permission: "admin.users.ban",
minRank: 7,
description: "Ban users",
groupName: "Users",
},
{
permission: "admin.users.reset_password",
minRank: 7,
description: "Reset passwords",
groupName: "Users",
},
{
permission: "admin.room.view",
minRank: 7,
description: "View rooms",
groupName: "Rooms",
},
{
permission: "admin.room.edit",
minRank: 7,
description: "Edit rooms",
groupName: "Rooms",
},
{
permission: "admin.room.delete",
minRank: 7,
description: "Delete rooms",
groupName: "Rooms",
},
{
permission: "admin.bans.view",
minRank: 7,
description: "View bans",
groupName: "Bans",
},
{
permission: "admin.news.view",
minRank: 7,
description: "View news",
groupName: "News",
},
{
permission: "admin.news.edit",
minRank: 7,
description: "Edit news",
groupName: "News",
},
{
permission: "admin.logs.view",
minRank: 7,
description: "View logs",
groupName: "Logs",
},
{
permission: "admin.settings.view",
minRank: 7,
description: "View settings",
groupName: "Settings",
},
{
permission: "admin.wordfilter.view",
minRank: 7,
description: "View wordfilter",
groupName: "Wordfilter",
},
{
permission: "admin.wordfilter.edit",
minRank: 7,
description: "Edit wordfilter",
groupName: "Wordfilter",
},
{
permission: "admin.catalog.view",
minRank: 7,
description: "View catalog",
groupName: "Catalog",
},
{
permission: "admin.shop.view",
minRank: 7,
description: "View shop",
groupName: "Shop",
},
{
permission: "admin.events.view",
minRank: 7,
description: "View events",
groupName: "Events",
},
{
permission: "admin.tickets.view",
minRank: 7,
description: "View tickets",
groupName: "Tickets",
},
{
permission: "admin.moderation.view",
minRank: 7,
description: "View moderation",
groupName: "Moderation",
},
{
permission: "admin.polls.view",
minRank: 7,
description: "View polls",
groupName: "Polls",
},
{
permission: "admin.analytics.view",
minRank: 7,
description: "View analytics",
groupName: "Analytics",
},
{
permission: "admin.prefixes.view",
minRank: 7,
description: "View prefixes",
groupName: "Prefixes",
},
{
permission: "admin.banners.view",
minRank: 7,
description: "View banners",
groupName: "Banners",
},
{
permission: "admin.radio.view",
minRank: 7,
description: "View radio",
groupName: "Radio",
},
{
permission: "admin.rcon.execute",
minRank: 7,
description: "Execute RCON",
groupName: "RCON",
},
{
permission: "mod.dashboard",
minRank: 7,
description: "Mod dashboard",
groupName: "Moderation",
},
{
permission: "mod.cfh.view",
minRank: 7,
description: "View CFH",
groupName: "Moderation",
},
{
permission: "mod.cfh.edit",
minRank: 7,
description: "Handle CFH",
groupName: "Moderation",
},
{
permission: "mod.actions",
minRank: 7,
description: "Mod actions",
groupName: "Moderation",
},
],
superadmin: [
{
permission: "admin.dashboard",
minRank: 8,
description: "Access admin dashboard",
groupName: "Core",
},
{
permission: "admin.users.view",
minRank: 8,
description: "View user profiles",
groupName: "Users",
},
{
permission: "admin.users.edit",
minRank: 8,
description: "Edit users",
groupName: "Users",
},
{
permission: "admin.users.ban",
minRank: 8,
description: "Ban users",
groupName: "Users",
},
{
permission: "admin.users.reset_password",
minRank: 8,
description: "Reset passwords",
groupName: "Users",
},
{
permission: "admin.room.view",
minRank: 8,
description: "View rooms",
groupName: "Rooms",
},
{
permission: "admin.room.edit",
minRank: 8,
description: "Edit rooms",
groupName: "Rooms",
},
{
permission: "admin.room.delete",
minRank: 8,
description: "Delete rooms",
groupName: "Rooms",
},
{
permission: "admin.bans.view",
minRank: 8,
description: "View bans",
groupName: "Bans",
},
{
permission: "admin.news.view",
minRank: 8,
description: "View news",
groupName: "News",
},
{
permission: "admin.news.edit",
minRank: 8,
description: "Edit news",
groupName: "News",
},
{
permission: "admin.logs.view",
minRank: 8,
description: "View logs",
groupName: "Logs",
},
{
permission: "admin.settings.view",
minRank: 8,
description: "View settings",
groupName: "Settings",
},
{
permission: "admin.settings.edit",
minRank: 8,
description: "Edit settings",
groupName: "Settings",
},
{
permission: "admin.permissions.manage",
minRank: 8,
description: "Manage permissions",
groupName: "Settings",
},
{
permission: "admin.wordfilter.view",
minRank: 8,
description: "View wordfilter",
groupName: "Wordfilter",
},
{
permission: "admin.wordfilter.edit",
minRank: 8,
description: "Edit wordfilter",
groupName: "Wordfilter",
},
{
permission: "admin.catalog.view",
minRank: 8,
description: "View catalog",
groupName: "Catalog",
},
{
permission: "admin.catalog.edit",
minRank: 8,
description: "Edit catalog",
groupName: "Catalog",
},
{
permission: "admin.shop.view",
minRank: 8,
description: "View shop",
groupName: "Shop",
},
{
permission: "admin.shop.edit",
minRank: 8,
description: "Edit shop",
groupName: "Shop",
},
{
permission: "admin.events.view",
minRank: 8,
description: "View events",
groupName: "Events",
},
{
permission: "admin.events.edit",
minRank: 8,
description: "Edit events",
groupName: "Events",
},
{
permission: "admin.tickets.view",
minRank: 8,
description: "View tickets",
groupName: "Tickets",
},
{
permission: "admin.tickets.edit",
minRank: 8,
description: "Edit tickets",
groupName: "Tickets",
},
{
permission: "admin.moderation.view",
minRank: 8,
description: "View moderation",
groupName: "Moderation",
},
{
permission: "admin.moderation.edit",
minRank: 8,
description: "Edit moderation",
groupName: "Moderation",
},
{
permission: "admin.polls.view",
minRank: 8,
description: "View polls",
groupName: "Polls",
},
{
permission: "admin.polls.edit",
minRank: 8,
description: "Edit polls",
groupName: "Polls",
},
{
permission: "admin.analytics.view",
minRank: 8,
description: "View analytics",
groupName: "Analytics",
},
{
permission: "admin.analytics.export",
minRank: 8,
description: "Export analytics",
groupName: "Analytics",
},
{
permission: "admin.devops.view",
minRank: 8,
description: "View devops",
groupName: "DevOps",
},
{
permission: "admin.devops.edit",
minRank: 8,
description: "Edit devops",
groupName: "DevOps",
},
{
permission: "admin.prefixes.view",
minRank: 8,
description: "View prefixes",
groupName: "Prefixes",
},
{
permission: "admin.prefixes.edit",
minRank: 8,
description: "Edit prefixes",
groupName: "Prefixes",
},
{
permission: "admin.banners.view",
minRank: 8,
description: "View banners",
groupName: "Banners",
},
{
permission: "admin.banners.edit",
minRank: 8,
description: "Edit banners",
groupName: "Banners",
},
{
permission: "admin.radio.view",
minRank: 8,
description: "View radio",
groupName: "Radio",
},
{
permission: "admin.radio.edit",
minRank: 8,
description: "Edit radio",
groupName: "Radio",
},
{
permission: "admin.rcon.execute",
minRank: 8,
description: "Execute RCON",
groupName: "RCON",
},
{
permission: "admin.assets.import",
minRank: 8,
description: "Import assets",
groupName: "Assets",
},
{
permission: "admin.export",
minRank: 8,
description: "Export data",
groupName: "Export",
},
{
permission: "admin.notifications.view",
minRank: 8,
description: "View notifications",
groupName: "Notifications",
},
{
permission: "admin.notifications.edit",
minRank: 8,
description: "Edit notifications",
groupName: "Notifications",
},
{
permission: "admin.pages.view",
minRank: 8,
description: "View CMS pages",
groupName: "CMS Pages",
},
{
permission: "admin.pages.edit",
minRank: 8,
description: "Edit CMS pages",
groupName: "CMS Pages",
},
{
permission: "mod.dashboard",
minRank: 8,
description: "Mod dashboard",
groupName: "Moderation",
},
{
permission: "mod.cfh.view",
minRank: 8,
description: "View CFH",
groupName: "Moderation",
},
{
permission: "mod.cfh.edit",
minRank: 8,
description: "Handle CFH",
groupName: "Moderation",
},
{
permission: "mod.actions",
minRank: 8,
description: "Mod actions",
groupName: "Moderation",
},
{
permission: "mod.team.view",
minRank: 8,
description: "View mod team",
groupName: "Moderation",
},
{
permission: "mod.tickets.view",
minRank: 8,
description: "View mod tickets",
groupName: "Moderation",
},
{
permission: "mod.tickets.edit",
minRank: 8,
description: "Edit mod tickets",
groupName: "Moderation",
},
{
permission: "mod.users.view",
minRank: 8,
description: "View users (mod)",
groupName: "Moderation",
},
{
permission: "mod.bans.view",
minRank: 8,
description: "View bans (mod)",
groupName: "Moderation",
},
],
};
const selected = presets[preset];
if (!selected) return;
for (const item of selected) {
const { permission, minRank, description, groupName } = item;
await prisma.websiteHousekeepingPermissions.upsert({
where: { permission },
update: {
minRank,
description,
groupName,
updatedBy: userId ?? undefined,
},
create: {
permission,
minRank,
description,
groupName,
updatedBy: userId ?? undefined,
},
});
}
await logAudit({
userId: userId ?? 0,
action: "housekeeping.preset.apply",
target: "WebsiteHousekeepingPermissions",
after: { preset, count: selected.length },
});
revalidatePath("/admin/housekeeping");
export async function applyPreset(_formData: FormData): Promise<void> {
return rejectLegacyHousekeepingWrite();
}
export async function clearAllPermissions(): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const userId = await currentUserId();
const count = await prisma.websiteHousekeepingPermissions.count();
await prisma.websiteHousekeepingPermissions.deleteMany();
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.clear_all",
target: "WebsiteHousekeepingPermissions",
after: { deletedCount: count },
});
revalidatePath("/admin/housekeeping");
return rejectLegacyHousekeepingWrite();
}
export async function bulkDeletePermissions(
ids: bigint[],
_ids: bigint[],
): Promise<{ count: number }> {
await requirePermission(PERMS.SETTINGS_EDIT);
if (!ids.length) return { count: 0 };
const userId = await currentUserId();
await prisma.websiteHousekeepingPermissions.deleteMany({
where: { id: { in: ids } },
});
await logAudit({
userId: userId ?? 0,
action: "housekeeping.permission.bulk_delete",
target: "WebsiteHousekeepingPermissions",
after: { deletedCount: ids.length },
});
revalidatePath("/admin/housekeeping");
return { count: ids.length };
return rejectLegacyHousekeepingWrite();
}
+182
View File
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
vi.hoisted(() => ({
mockUpsert: vi.fn(),
mockRequirePermission: vi.fn(),
mockReload: vi.fn(),
mockRevalidatePath: vi.fn(),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
ADMIN_DASHBOARD: "admin.dashboard",
SETTINGS_VIEW: "admin.settings.view",
SETTINGS_EDIT: "admin.settings.edit",
},
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteSetting: { upsert: mockUpsert },
},
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: mockRequirePermission,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: mockReload },
}));
vi.mock("next/cache", () => ({
revalidatePath: mockRevalidatePath,
}));
import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => {
vi.clearAllMocks();
});
describe("saveMaintenance", () => {
it("enables maintenance mode with message and min rank", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "We will be back soon!");
fd.set("min_rank", "3");
await saveMaintenance(fd);
expect(mockRequirePermission).toHaveBeenCalled();
expect(mockUpsert).toHaveBeenCalledTimes(3);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "1" },
create: expect.objectContaining({
key: "maintenance_enabled",
value: "1",
}),
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_message" },
update: { value: "We will be back soon!" },
create: expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
}),
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "3" },
create: expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
}),
}),
);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
});
it("disables maintenance mode", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "0" },
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("defaults min_rank to 5 when input is empty", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("defaults min_rank to 5 when input is negative", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "");
fd.set("min_rank", "-1");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("calls requirePermission with SETTINGS_EDIT", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockRequirePermission).toHaveBeenCalledWith("admin.settings.edit");
});
});
+36
View File
@@ -0,0 +1,36 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */
export async function cancelMarketplaceListing(
formData: FormData,
): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const listing = await prisma.marketplaceItems.findUnique({
where: { id },
select: { id: true, state: true, userId: true, itemId: true, price: true },
});
if (listing?.state !== 1) return;
await prisma.marketplaceItems.update({
where: { id },
data: { state: 0 },
});
await logStaffActivity({
staffId: staff.id,
action: "marketplace_cancel",
description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`,
targetType: "marketplace",
targetId: id,
});
revalidatePath("/admin/marketplace");
}
+5 -2
View File
@@ -4,8 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
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"];
@@ -19,7 +19,10 @@ export async function uploadMedia(
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" };
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
+1 -1
View File
@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
+1 -1
View File
@@ -11,8 +11,8 @@ import {
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
+1 -1
View File
@@ -3,8 +3,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
+5 -2
View File
@@ -7,6 +7,7 @@ import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
import {
deleteCustomThemeStore,
getCustomTheme,
@@ -15,7 +16,6 @@ import {
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
@@ -46,7 +46,10 @@ export async function saveTheme(formData: FormData): Promise<void> {
}
const fixed = ensureReadableThemeColors(bag);
for (const [key, value] of Object.entries(fixed)) {
await writeSetting(settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), value);
await writeSetting(
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
value,
);
}
}
const ADMIN_KEYS = [
+2 -2
View File
@@ -1,14 +1,14 @@
"use server";
import { revalidatePath } from "next/cache";
import { positiveBigInt } from "@/lib/api";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
actionOk,
type ActionResult,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
+2 -4
View File
@@ -5,9 +5,9 @@ import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
type ActionResult,
actionError,
actionOk,
type ActionResult,
} from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
@@ -33,9 +33,7 @@ export async function addWord(input: {
}
}
export async function deleteWord(input: {
id: string;
}): Promise<ActionResult> {
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(input.id ?? "").normalize("NFC");
if (!raw) return actionError("Missing word id");
+86 -38
View File
@@ -1,98 +1,146 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// AtomCMS validates the application body with `min:10`. Mirror that floor and
// cap the write defensively (column is TEXT, but we keep applications sane).
const CONTENT_MIN = 10;
const CONTENT_MAX = 5000;
type ApplyOutcome =
| "submitted"
| "empty"
| "invalid"
| "duplicate"
| "ratelimit"
| "error";
function staffRedirect(outcome: ApplyOutcome): never {
if (outcome === "submitted") redirect("/apply/staff?submitted=1");
redirect(`/apply/staff?error=${outcome}`);
}
function teamRedirect(outcome: ApplyOutcome): never {
if (outcome === "submitted") redirect("/apply/team?submitted=1");
redirect(`/apply/team?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Submit a STAFF application for an open position.
*
* Faithful to AtomCMS's StaffApplicationsController@store:
* - the applicant (user_id) is re-read from the session via auth() and is
* NEVER trusted from the submitted FormData;
* - rank_id is the open position's permission id (the rank being applied for);
* - a user may only apply once per rank (idempotency guard);
* - content must be at least 10 characters.
*/
export async function applyStaff(formData: FormData): Promise<void> {
let outcome: ApplyOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
// rank_id comes from the open position's permission_id (an Int in the schema).
await clientIp();
if (!(await rateLimit(`apply-staff:${userId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const rankId = Number(formData.get("rankId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
if (!Number.isInteger(rankId) || rankId <= 0) {
outcome = "invalid";
} else {
const content = String(formData.get("content") ?? "")
.normalize("NFC")
.trim()
.slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
if (content.length < CONTENT_MIN) {
outcome = "empty";
} else {
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) return;
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "submitted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/apply/staff");
staffRedirect(outcome);
}
/**
* Submit a TEAM application.
*
* The Prisma `website_staff_applications` slice has no dedicated team column, so
* (per the conversion brief) team applications REUSE the staff-applications
* table with the team acting as the rank: rank_id carries the team id. The
* applicant is re-read from the session, never trusted from the form, and a user
* may only apply once per team.
*/
export async function applyTeam(formData: FormData): Promise<void> {
let outcome: ApplyOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
// id is the application's rank flag.
await clientIp();
if (!(await rateLimit(`apply-team:${userId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const rankId = Number(formData.get("teamId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
if (!Number.isInteger(rankId) || rankId <= 0) {
outcome = "invalid";
} else {
const content = String(formData.get("content") ?? "")
.normalize("NFC")
.trim()
.slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
if (content.length < CONTENT_MIN) {
outcome = "empty";
} else {
const existing = await prisma.websiteStaffApplications.findFirst({
where: { userId, rankId },
select: { id: true },
});
if (existing) return;
if (existing) {
outcome = "duplicate";
} else {
const now = new Date();
await prisma.websiteStaffApplications.create({
data: { userId, rankId, content, createdAt: now, updatedAt: now },
});
} catch {
return;
outcome = "submitted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/apply/team");
teamRedirect(outcome);
}
+69 -24
View File
@@ -1,57 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
const COMMENT_MAX = 255;
type CommentOutcome =
| "posted"
| "empty"
| "invalid"
| "moderated"
| "ratelimit"
| "not_found"
| "error";
function commentRedirect(slug: string, outcome: CommentOutcome): never {
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
if (outcome === "posted") redirect(`${path}?comment=posted`);
redirect(`${path}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a comment on a news article as the SIGNED-IN user. The author id is read
* from the session (re-fetched via auth()), never from the submitted FormData,
* so a crafted form cannot post as another account. The articleId comes from the
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?comment=posted.
*/
export async function postComment(formData: FormData): Promise<void> {
const slugHint = String(formData.get("slug") ?? "")
.normalize("NFC")
.trim();
let outcome: CommentOutcome = "error";
let slug = slugHint;
try {
const session = await auth();
if (!session?.user?.id) return;
if (!session?.user?.id) {
redirect("/login");
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
if (!Number.isFinite(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`comment:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, COMMENT_MAX);
if (!comment) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(comment)).ok) return;
if (!comment) {
outcome = "empty";
} else if (!(await isAllowed(comment)).ok) {
outcome = "moderated";
} else {
const articleIdRaw = String(formData.get("articleId") ?? "")
.normalize("NFC")
.trim();
if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
let slug: string | null;
try {
// Confirm the article exists (and grab its slug for revalidation).
if (!/^\d+$/.test(articleIdRaw)) {
outcome = "invalid";
} else {
const articleId = BigInt(articleIdRaw);
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
@@ -62,10 +101,16 @@ export async function postComment(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "posted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (slug) revalidatePath(`/news/${slug}`);
commentRedirect(slug, outcome);
}
+63 -36
View File
@@ -1,79 +1,100 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// The reaction set the UI offers. The action rejects anything outside this list
// so the website_article_reactions.reaction VARCHAR(50) only ever holds known
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
type ReactionOutcome =
| "updated"
| "invalid"
| "not_found"
| "ratelimit"
| "error";
function reactionRedirect(slug: string, outcome: ReactionOutcome): never {
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
if (outcome === "updated") redirect(`${path}?reaction=1`);
redirect(`${path}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Toggle the SIGNED-IN user's reaction on a news article.
*
* The voter id is read from the session (re-fetched via auth()), never from the
* submitted FormData, so a crafted form cannot vote as another account. A user
* has at most one ACTIVE reaction per article:
* - clicking the reaction they already have active -> deactivates it (un-vote)
* - clicking a different reaction -> that reaction becomes active and any other
* reaction rows for this user/article are deactivated
* - first-ever reaction of a type -> a new active row is created
*
* Rows are toggled (active flag) rather than deleted so a user's history of
* reaction types is preserved. website_article_reactions has no composite
* unique key, so we resolve the existing row with findFirst rather than upsert.
*/
export async function toggleReaction(formData: FormData): Promise<void> {
const slugHint = String(formData.get("slug") ?? "")
.normalize("NFC")
.trim();
let outcome: ReactionOutcome = "error";
let slug = slugHint;
try {
const session = await auth();
if (!session?.user?.id) return;
if (!session?.user?.id) {
redirect("/login");
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
if (!Number.isFinite(userId)) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) {
outcome = "ratelimit";
} else {
const reaction = String(formData.get("reaction") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
if (!ALLOWED_REACTIONS.has(reaction)) return;
if (!ALLOWED_REACTIONS.has(reaction)) {
outcome = "invalid";
} else {
const articleIdRaw = String(formData.get("articleId") ?? "")
.normalize("NFC")
.trim();
if (!/^\d+$/.test(articleIdRaw)) return;
if (!/^\d+$/.test(articleIdRaw)) {
outcome = "invalid";
} else {
const articleId = BigInt(articleIdRaw);
let articleId: bigint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
let slug: string | null;
try {
// Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
if (!article) {
outcome = "not_found";
} else {
slug = article.slug;
// The user's current row for THIS reaction on THIS article, if any.
const existing = await prisma.websiteArticleReactions.findFirst({
where: { userId, articleId, reaction },
select: { id: true, active: true },
});
if (existing?.active) {
// Already reacting with this exact reaction -> un-vote (deactivate it).
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: false },
});
} else {
// Switching to (or first-time picking) this reaction: clear any other
// active reaction by this user on this article, then activate this one.
await prisma.websiteArticleReactions.updateMany({
where: { userId, articleId, active: true },
data: { active: false },
@@ -90,10 +111,16 @@ export async function toggleReaction(formData: FormData): Promise<void> {
});
}
}
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "updated";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (slug) revalidatePath(`/news/${slug}`);
if (slug && outcome !== "error") revalidatePath(`/news/${slug}`);
reactionRedirect(slug, outcome);
}
+37 -5
View File
@@ -4,16 +4,25 @@ import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export type PrecheckResult = "ok" | "invalid" | "twofactor";
export type PrecheckResult =
| "ok"
| "invalid"
| "twofactor"
| "unverified"
| "captcha";
/**
* Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
* Also enforces captcha + optional email-verification when configured.
*/
export async function precheckLogin(
username: string,
password: string,
captchaToken?: string | null,
): Promise<PrecheckResult> {
const u = String(username ?? "")
.normalize("NFC")
@@ -21,14 +30,29 @@ export async function precheckLogin(
const p = String(password ?? "");
if (!u || !p) return "invalid";
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
return "invalid";
const ip = await clientIp();
if (!(await rateLimit(`precheck:${ip}`, 10, 5 * 60_000)).ok) return "invalid";
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
}
let user: {
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
} | null;
try {
user = await prisma.user.findUnique({
where: { username: u },
select: { password: true, twoFactorConfirmedAt: true },
select: {
password: true,
twoFactorConfirmedAt: true,
mail: true,
mailVerified: true,
},
});
} catch {
return "invalid";
@@ -50,5 +74,13 @@ export async function precheckLogin(
});
if (!res.valid) return "invalid";
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return "unverified";
}
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
}
+212 -175
View File
@@ -1,230 +1,267 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
async function requireRcon(): Promise<void> {
await requirePermission(PERMS.RCON_EXECUTE);
const RCON_FAIL = "RCON command failed. Is the emulator running?";
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> {
await requireRcon();
try {
await rcon.updateCatalog();
} catch {
// RCON is best-effort; a dead socket must not 500 the admin page.
}
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateCatalog());
revalidatePath(PATH);
}
return actionOk();
},
);
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> {
await requireRcon();
try {
await rcon.updateWordFilter();
} catch {
// best-effort
}
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateWordFilter());
revalidatePath(PATH);
}
return actionOk();
},
);
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> {
await requireRcon();
try {
await rcon.send("updatenavigator", null);
} catch {
// best-effort
}
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.send("updatenavigator", null));
revalidatePath(PATH);
}
return actionOk();
},
);
const hotelAlertSchema = z.object({
message: z.string().trim().min(1).max(512),
});
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireRcon();
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 512);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
} catch {
// best-effort
}
export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.send("hotelalert", { message }));
revalidatePath(PATH);
}
return actionOk();
},
);
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
username: z.string().trim().min(1),
});
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
export async function disconnectUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
if (!userId || !username) return;
try {
await rcon.disconnectUser(userId, username);
} catch {
// best-effort
}
export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
revalidatePath(PATH);
}
return actionOk();
},
);
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().trim().min(1).max(512),
});
/** Send an alert to a specific user (rcon: alertuser). */
export async function alertUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 512);
if (!userId || !message) return;
try {
await rcon.alertUser(userId, message);
} catch {
// best-effort
}
export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
revalidatePath(PATH);
}
return actionOk();
},
);
const forwardUserSchema = z.object({
userId: z.coerce.number().int().positive(),
roomId: z.coerce.number().int().positive(),
});
/** Forward a user to a specific room (rcon: forwarduser). */
export async function forwardUser(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const roomId = Number(formData.get("roomId"));
if (!userId || !roomId) return;
try {
await rcon.forwardUser(userId, roomId);
} catch {
// best-effort
}
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await requireRconOk(
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
);
revalidatePath(PATH);
}
return actionOk();
},
);
const giveCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
credits: z.coerce.number().int().positive(),
});
/** Give credits to a user (rcon: givecredits). */
export async function giveCredits(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const credits = Number(formData.get("credits"));
if (!userId || !credits || credits <= 0) return;
try {
await rcon.giveCredits(userId, credits);
} catch {
// best-effort
}
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
revalidatePath(PATH);
}
return actionOk();
},
);
const giveAmountSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().positive(),
});
/** Give duckets to a user (rcon: givepoints type=duckets). */
export async function giveDuckets(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
try {
await rcon.giveDuckets(userId, amount);
} catch {
// best-effort
}
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
}
return actionOk();
},
);
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
export async function giveDiamonds(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
try {
await rcon.giveDiamonds(userId, amount);
} catch {
// best-effort
}
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
}
return actionOk();
},
);
const giveBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badge: z.string().trim().min(1).max(32),
});
/** Give a badge to a user (rcon: givebadge). */
export async function giveBadge(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim();
if (!userId || !badge) return;
try {
await rcon.giveBadge(userId, badge);
} catch {
// best-effort
}
export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
const badge = ctx.data.badge.normalize("NFC");
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
revalidatePath(PATH);
}
return actionOk();
},
);
const setMottoSchema = z.object({
userId: z.coerce.number().int().positive(),
motto: z.string().trim().min(1).max(127),
});
/** Set a user's motto (rcon: setmotto). */
export async function setMotto(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "")
.normalize("NFC")
.trim()
.slice(0, 127);
if (!userId || !motto) return;
try {
await rcon.setMotto(userId, motto);
} catch {
// best-effort
}
export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
const motto = ctx.data.motto.normalize("NFC");
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
revalidatePath(PATH);
}
return actionOk();
},
);
const setRankSchema = z.object({
userId: z.coerce.number().int().positive(),
rank: z.coerce.number().int().min(1).max(9999),
});
/** Set a user's rank (rcon: setrank). */
export async function setRank(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank"));
if (!userId || rank < 0 || rank > 10) return;
try {
await rcon.setRank(userId, rank);
} catch {
// best-effort
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const target = await prisma.user.findUnique({
where: { id: ctx.data.userId },
select: { rank: true },
});
if (!target) throw new ActionError("User not found");
const rankExists = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1
`.catch(() => [] as { id: number }[]);
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
if (target.rank >= staffRank) {
throw new ActionError(
"Cannot change rank of a user at or above your rank",
);
}
if (ctx.data.rank >= staffRank) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await prisma.user.update({
where: { id: ctx.data.userId },
data: { rank: ctx.data.rank },
});
revalidatePath(PATH);
}
return actionOk();
},
);
const executeCommandSchema = z.object({
userId: z.coerce.number().int().positive(),
command: z.string().trim().min(1).max(100),
});
/** Execute a command as a user (rcon: executecommand). */
export async function executeCommand(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const command = String(formData.get("command") ?? "")
.normalize("NFC")
.trim();
if (!userId || !command) return;
try {
await rcon.executeCommand(userId, command);
} catch {
// best-effort
}
export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
revalidatePath(PATH);
}
return actionOk();
},
);
const sendGiftSchema = z.object({
userId: z.coerce.number().int().positive(),
itemId: z.coerce.number().int().positive(),
message: z.string().trim().max(255).optional().default("Here is a gift."),
});
/** Send a gift to a user (rcon: sendgift). */
export async function sendGift(formData: FormData): Promise<void> {
await requireRcon();
const userId = Number(formData.get("userId"));
const itemId = Number(formData.get("itemId"));
const message = String(formData.get("message") ?? "Here is a gift.")
.trim()
.slice(0, 255);
if (!userId || !itemId) return;
try {
await rcon.sendGift(userId, itemId, message);
} catch {
// best-effort
}
export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
await requireRconOk(
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
);
revalidatePath(PATH);
}
return actionOk();
},
);
+11 -3
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
.trim();
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
let outcome: "bought" | "invalid" | "credits" | "fail";
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
let boughtCode = "";
try {
await clientIp();
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true },
@@ -84,7 +89,8 @@ export async function buyBadge(formData: FormData): Promise<void> {
// Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user.
if (price > 0) {
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await prisma.$transaction(
async (tx: Prisma.TransactionClient) => {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
@@ -104,7 +110,8 @@ export async function buyBadge(formData: FormData): Promise<void> {
data: { userId, slotId, badgeCode: code },
});
}
});
},
);
}
// Grant the badge live so it appears immediately for online users.
@@ -115,6 +122,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
}
}
}
}
} catch {
outcome = "fail";
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGet = vi.hoisted(() => vi.fn());
const mockSendMail = vi.hoisted(() => vi.fn());
const mockGetTranslations = vi.hoisted(() => vi.fn());
vi.mock("@/env", () => ({
env: {
APP_KEY: "test-app-key-for-hmac",
AUTH_SECRET: "",
APP_URL: "http://localhost:3000",
HOTEL_NAME: "TestHotel",
},
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mockGet },
}));
vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail,
}));
vi.mock("next-intl/server", () => ({
getTranslations: mockGetTranslations,
}));
import {
isValidVerificationToken,
sendVerification,
verificationToken,
} from "./email-verify";
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockResolvedValue("TestHotel");
mockSendMail.mockResolvedValue(true);
mockGetTranslations.mockRejectedValue(new Error("missing"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("email verification tokens", () => {
it("issues timestamped HMAC tokens that validate", async () => {
const token = await verificationToken("[email protected]");
expect(token).toMatch(/^\d+\.[a-f0-9]{64}$/);
expect(await isValidVerificationToken("[email protected]", token)).toBe(
true,
);
});
it("rejects legacy forever-valid digests", async () => {
const legacy = "a".repeat(64);
expect(await isValidVerificationToken("[email protected]", legacy)).toBe(
false,
);
});
it("rejects expired tokens", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const token = await verificationToken("[email protected]");
vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); // > 24h
expect(await isValidVerificationToken("[email protected]", token)).toBe(
false,
);
});
it("sends mail with a verify link", async () => {
mockGetTranslations.mockResolvedValue(((
key: string,
values?: { hotel?: string },
) => {
const map: Record<string, string> = {
subject: `Verify your email · ${values?.hotel}`,
heading: "Verify your email",
body: `Welcome to ${values?.hotel}!`,
button: "Verify email",
fallback: "Paste this link:",
};
return map[key] ?? key;
}) as never);
await sendVerification("[email protected]");
expect(mockSendMail).toHaveBeenCalledWith(
"[email protected]",
expect.stringContaining("Verify your email"),
expect.stringContaining("/verify?token="),
);
});
});
+62 -27
View File
@@ -1,20 +1,21 @@
"use server";
import { createHash, timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
import { env } from "@/env";
import { resolveHotelName } from "@/lib/hotel-name";
import { sendMail } from "@/lib/services/email";
import { siteSettings } from "@/lib/services/site-settings";
// Stateless email verification, AtomCMS-faithful but DB-table-free.
// Stateless email verification with a time-limited HMAC token.
//
// Instead of persisting a row (password_resets style), the token is a keyed
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
// server-only secret, an attacker who only knows the email cannot forge a
// matching token, and /verify can recompute + compare it without any storage.
// The token is therefore deterministic per (email, secret) pair and stays valid
// until the account's mail_verified flips to '1' (after which /verify no-ops).
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
@@ -24,26 +25,43 @@ function verifySecret(): string {
return secret;
}
/** Compute the verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
return createHash("sha256")
.update(`${normalised}|${verifySecret()}`)
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing.
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
const expected = await verificationToken(email);
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(token.toLowerCase(), "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
@@ -60,25 +78,42 @@ export async function sendVerification(email: string): Promise<boolean> {
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName =
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
const hotelName = await resolveHotelName();
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
Verify email
${escapeHtml(button)}
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {
+64 -2
View File
@@ -1,16 +1,18 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
registerForEventSchema,
updateEventSchema,
} from "@/lib/validators/event";
@@ -193,3 +195,63 @@ export const addEventWinner = adminAction(
return actionOk({ id: winner.id });
},
);
// ── Public site: register ───────────────────────────────────────────
export const registerForEvent = authAction(
{
schema: registerForEventSchema,
rateLimitKey: "event-register",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
if (!event) return actionError("Event not found");
if (event.status !== "published") {
return actionError("This event is not open for registration");
}
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.type.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.type.minRank) {
return actionError("Your rank is too low to join this event");
}
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
}
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
});
revalidatePath("/events");
revalidatePath(`/events/${event.id}`);
return actionOk({ eventId: event.id });
},
);
+64 -22
View File
@@ -1,13 +1,39 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { isAllowed } from "@/lib/services/moderation";
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
const MESSAGE_MAX = 255;
type GuestbookOutcome =
| "posted"
| "empty"
| "invalid"
| "moderated"
| "ratelimit"
| "error";
function guestbookRedirect(username: string, outcome: GuestbookOutcome): never {
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
if (outcome === "posted") redirect(`${path}?guestbook=posted`);
redirect(`${path}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a guestbook entry on a profile.
*
@@ -15,32 +41,43 @@ const MESSAGE_MAX = 255;
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
* taken from the form, and we resolve a profile username from the form purely
* to revalidate the right page.
* to revalidate / redirect to the right page.
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?guestbook=posted.
*/
export async function postGuestbook(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(message)).ok) return;
// Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
const now = new Date();
let outcome: GuestbookOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`guestbook:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) {
outcome = "invalid";
} else {
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "empty";
} else if (!(await isAllowed(message)).ok) {
outcome = "moderated";
} else {
const now = new Date();
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
@@ -50,10 +87,15 @@ export async function postGuestbook(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "posted";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (username) revalidatePath(`/u/${username}`);
guestbookRedirect(username, outcome);
}
+217 -7
View File
@@ -1,26 +1,78 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
content: z.string().min(1, "Content is required").max(5000),
});
type TicketOutcome =
| "created"
| "invalid"
| "moderated"
| "ratelimit"
| "error";
type TicketDetailOutcome =
| "replied"
| "closed"
| "invalid"
| "not_found"
| "closed_ticket"
| "moderated"
| "ratelimit"
| "error";
function ticketsRedirect(outcome: TicketOutcome): never {
if (outcome === "created") redirect("/help/tickets?created=1");
redirect(`/help/tickets?error=${outcome}`);
}
function ticketDetailRedirect(
ticketId: bigint | null,
outcome: TicketDetailOutcome,
): never {
if (!ticketId) redirect("/help/tickets?error=invalid");
const base = `/help/tickets/${ticketId}`;
if (outcome === "replied") redirect(`${base}?replied=1`);
if (outcome === "closed") redirect(`${base}?closed=1`);
redirect(`${base}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
export async function createTicket(formData: FormData): Promise<void> {
let outcome: TicketOutcome = "error";
try {
// Re-read the session user id server-side; never trust a form-supplied id.
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const raw = {
title: String(formData.get("title") ?? "")
.normalize("NFC")
@@ -33,17 +85,20 @@ export async function createTicket(formData: FormData): Promise<void> {
};
const parsed = ticketSchema.safeParse(raw);
if (!parsed.success) return;
if (!parsed.success) {
outcome = "invalid";
} else {
const { title, content } = parsed.data;
// Moderation check
let moderated = false;
try {
await moderateOrThrow(`${title} ${content}`);
} catch {
return;
moderated = true;
outcome = "moderated";
}
if (!moderated) {
const now = new Date();
await prisma.websiteHelpCenterTickets.create({
data: {
@@ -55,6 +110,161 @@ export async function createTicket(formData: FormData): Promise<void> {
updatedAt: now,
},
});
outcome = "created";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/help/tickets");
ticketsRedirect(outcome);
}
const replyContentSchema = z.object({
content: z.string().min(1).max(5000),
});
export async function replyHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
if (!ticketId) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`ticket-reply:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const raw = {
content: String(formData.get("content") ?? "")
.normalize("NFC")
.trim()
.slice(0, 5000),
};
const parsed = replyContentSchema.safeParse(raw);
if (!parsed.success) {
outcome = "invalid";
} else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
} else if (!ticket.open) {
outcome = "closed_ticket";
} else {
let moderated = false;
try {
await moderateOrThrow(parsed.data.content);
} catch {
moderated = true;
outcome = "moderated";
}
if (!moderated) {
const created = await prisma.$transaction((tx) =>
createOwnedTicketReply(
{
findTicket: (id) =>
tx.websiteHelpCenterTickets.findUnique({
where: { id },
select: { id: true, userId: true, open: true },
}),
createReply: (data) =>
tx.websiteHelpCenterTicketReplies.create({
data,
select: {
id: true,
userId: true,
content: true,
createdAt: true,
},
}),
touchTicket: (id, updatedAt) =>
tx.websiteHelpCenterTickets.update({
where: { id },
data: { updatedAt },
select: { id: true },
}),
},
{
ticketId,
userId,
content: parsed.data.content,
},
),
);
outcome = created ? "replied" : "not_found";
}
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
revalidatePath("/help/tickets");
ticketDetailRedirect(ticketId, outcome);
}
export async function closeHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
if (!ticketId) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: { id: true, userId: true, open: true },
});
if (!ticket || ticket.userId !== userId) {
outcome = "not_found";
} else if (!ticket.open) {
outcome = "closed_ticket";
} else {
const now = new Date();
await prisma.websiteHelpCenterTickets.update({
where: { id: ticketId },
data: { open: false, updatedAt: now },
});
outcome = "closed";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
revalidatePath("/help/tickets");
ticketDetailRedirect(ticketId, outcome);
}
-66
View File
@@ -1,66 +0,0 @@
"use server";
import { auth } from "@/lib/auth";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
export async function linkDiscordId(discordId: string): Promise<string | null> {
const session = await auth();
if (!session?.user?.id) return "Not logged in";
const userId = Number(session.user.id);
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
return "Invalid Discord ID format";
}
const discordIdClean = discordId.trim();
try {
const existing = await prisma.socialAccounts.findUnique({
where: {
provider_providerId: {
provider: "discord",
providerId: discordIdClean,
},
},
select: { userId: true },
});
if (existing && Number(existing.userId) !== userId) {
return "This Discord ID is already linked to another account";
}
} catch {
return "Failed to check Discord ID";
}
try {
await prisma.socialAccounts.upsert({
where: {
provider_providerId: {
provider: "discord",
providerId: discordIdClean,
},
},
create: {
userId: BigInt(userId),
provider: "discord",
providerId: discordIdClean,
createdAt: new Date(),
updatedAt: new Date(),
},
update: { userId: BigInt(userId), updatedAt: new Date() },
});
await prisma.user.update({
where: { id: userId },
data: { mailVerified: "1" },
});
return null;
} catch (e) {
logger.error("Failed to link Discord account", {
module: "link-discord",
error: (e as Error).message,
});
return "Failed to link Discord account";
}
}
+304 -18
View File
@@ -1,8 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
type FriendOutcome =
| "accepted"
| "declined"
| "removed"
| "not_found"
| "unauthorized"
| "invalid"
| "ratelimit"
| "error";
type SendOutcome =
| "sent"
| "not_friend"
| "empty"
| "invalid"
| "rate_limited"
| "error";
function messagesRedirect(outcome: FriendOutcome): never {
if (outcome === "accepted") redirect("/messages?accepted=1");
if (outcome === "declined") redirect("/messages?declined=1");
redirect(`/messages?error=${outcome}`);
}
function friendsRedirect(outcome: FriendOutcome): never {
if (outcome === "removed") redirect("/friends?removed=1");
redirect(`/friends?error=${outcome}`);
}
function sendRedirect(outcome: SendOutcome): never {
if (outcome === "sent") redirect("/messages?sent=1");
redirect(`/messages?send_error=${outcome}`);
}
/**
* Accept a pending friend request as the SIGNED-IN user.
@@ -16,30 +52,51 @@ import { prisma } from "@/lib/prisma";
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
* create both inside a transaction and delete the originating request so it no
* longer shows as pending in the in-game messenger or here.
*
* Errors redirect back to /messages with a machine-readable ?error= code;
* success redirects with ?accepted=1. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function acceptFriend(formData: FormData): Promise<void> {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) return;
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) return;
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
// The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userFromId: true, userToId: true },
});
if (!request || request.userToId !== meId) return;
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
const friendId = request.userFromId;
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
// Malformed/self request — just clear it.
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
return;
}
if (
!Number.isInteger(friendId) ||
friendId <= 0 ||
friendId === meId
) {
// Malformed/self request — clear it and treat as not found.
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
outcome = "not_found";
} else {
const friendsSince = Math.floor(Date.now() / 1000);
await prisma.$transaction(async (tx) => {
@@ -63,13 +120,242 @@ export async function acceptFriend(formData: FormData): Promise<void> {
});
}
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
// Clear this request and any reverse pending request between the pair.
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ id: requestId },
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
});
outcome = "accepted";
}
}
}
}
} catch (e) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
revalidatePath("/friends");
messagesRedirect(outcome);
}
/**
* Decline a pending friend request as the SIGNED-IN user.
*
* Only the request's target (user_to_id) may decline. Deletes the
* messenger_friendrequests row without creating a friendship.
*/
export async function declineFriendRequest(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) {
outcome = "invalid";
} else {
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userToId: true },
});
if (!request) {
outcome = "not_found";
} else if (request.userToId !== meId) {
outcome = "unauthorized";
} else {
await prisma.messengerFriendrequests.delete({
where: { id: requestId },
});
outcome = "declined";
}
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
messagesRedirect(outcome);
}
/**
* Remove an existing friendship between the SIGNED-IN user and another user.
*
* Deletes BOTH directional rows in messenger_friendships (Arcturus stores one
* row each way) and clears any leftover pending requests between the pair.
* Only the friend id comes from the form; the session user is never trusted
* from FormData.
*/
export async function removeFriendship(formData: FormData): Promise<void> {
let outcome: FriendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
outcome = "ratelimit";
} else {
const friendId = Number(formData.get("friendId"));
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else {
const deleted = await prisma.$transaction(async (tx) => {
const result = await tx.messengerFriendships.deleteMany({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
});
await tx.messengerFriendrequests.deleteMany({
where: {
OR: [
{ userFromId: meId, userToId: friendId },
{ userFromId: friendId, userToId: meId },
],
},
});
return result.count;
});
outcome = deleted > 0 ? "removed" : "not_found";
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/friends");
revalidatePath("/messages");
friendsRedirect(outcome);
}
/**
* Send an offline messenger message to a friend (Arcturus messenger_offline row).
* Only confirmed friendships may receive messages; body is capped at 500 chars.
*/
export async function sendOfflineMessage(formData: FormData): Promise<void> {
let outcome: SendOutcome = "error";
try {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) {
redirect("/login");
}
if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) {
outcome = "rate_limited";
} else {
const friendId = Number(formData.get("friendId"));
const rawMessage = String(formData.get("message") ?? "")
.normalize("NFC")
.trim();
const message = rawMessage.slice(0, 500);
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
outcome = "invalid";
} else if (!message) {
outcome = "empty";
} else {
const friendship = await prisma.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
});
if (!friendship) {
outcome = "not_friend";
} else {
const recipient = await prisma.user.findUnique({
where: { id: friendId },
select: { id: true },
});
if (!recipient) {
outcome = "invalid";
} else {
await prisma.messengerOffline.create({
data: {
userId: friendId,
userFromId: meId,
message,
sendedOn: Math.floor(Date.now() / 1000),
},
});
outcome = "sent";
}
}
}
}
} catch (e) {
if (
e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
) {
throw e;
}
outcome = "error";
}
revalidatePath("/messages");
sendRedirect(outcome);
}
+12 -9
View File
@@ -12,8 +12,11 @@ import { rcon } from "@/lib/services/rcon";
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
export const assignCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
@@ -42,7 +45,7 @@ const cfhStateSchema = z.object({
});
export const updateCfhState = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const ticket = await prisma.supportTickets.findUnique({
where: { id: ctx.data.ticketId },
@@ -68,7 +71,7 @@ export const updateCfhState = adminAction(
);
export const closeCfhTicket = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
await prisma.supportTickets.update({
where: { id: ctx.data.ticketId },
@@ -91,7 +94,7 @@ export const closeCfhTicket = adminAction(
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId);
@@ -112,7 +115,7 @@ const muteSchema = z.object({
});
export const quickMute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
{ permission: MOD_ACTION_PERM, schema: muteSchema },
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
@@ -129,7 +132,7 @@ export const quickMute = adminAction(
);
export const quickUnmute = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId);
@@ -150,7 +153,7 @@ const alertSchema = z.object({
});
export const quickAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
{ permission: MOD_ACTION_PERM, schema: alertSchema },
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message);
@@ -169,7 +172,7 @@ export const quickAlert = adminAction(
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
async (ctx) => {
await rcon.kickAll(ctx.data.roomId);
@@ -190,7 +193,7 @@ const broadcastSchema = z.object({
});
export const broadcastAlert = adminAction(
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
async (ctx) => {
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
+9
View File
@@ -40,6 +40,15 @@ vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail,
}));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: vi.fn().mockResolvedValue({
provider: "none",
siteKey: "",
field: "",
}),
verifyCaptcha: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/rate-limit", () => ({
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
+13 -2
View File
@@ -6,6 +6,7 @@ import { env } from "@/env";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { sendMail } from "@/lib/services/email";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -20,9 +21,19 @@ export async function requestReset(formData: FormData): Promise<void> {
.trim()
.toLowerCase();
const ip = await clientIp();
// CAPTCHA when a provider is configured (mirrors register).
const cfg = await captchaConfig();
if (cfg.provider !== "none") {
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
if (!(await verifyCaptcha(token, ip))) {
redirect("/forgot?error=captcha");
}
}
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
.ok;
const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
// Always respond the same way so we don't reveal which emails exist.
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+113 -2
View File
@@ -1,15 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
voteOnPollSchema,
} from "@/lib/validators/poll";
// ── Polls ───────────────────────────────────────────────────────────
@@ -113,3 +115,112 @@ export const deletePollQuestion = adminAction(
return actionOk();
},
);
// ── Public site: vote ───────────────────────────────────────────────
function parsePollOptions(options: string): string[] {
return options
.split("\n")
.map((o) => o.trim())
.filter(Boolean);
}
export const voteOnPoll = authAction(
{
schema: voteOnPollSchema,
rateLimitKey: "poll-vote",
rateLimitMax: 20,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const poll = await prisma.websitePoll.findUnique({
where: { id: ctx.data.pollId },
include: { questions: true },
});
if (!poll) return actionError("Poll not found");
if (poll.status !== "active") {
return actionError("This poll is not open for voting");
}
const now = Date.now();
if (poll.startsAt && poll.startsAt.getTime() > now) {
return actionError("This poll has not started yet");
}
if (poll.endsAt && poll.endsAt.getTime() < now) {
return actionError("This poll has ended");
}
const questionById = new Map(poll.questions.map((q) => [q.id, q]));
const seen = new Set<number>();
for (const vote of ctx.data.votes) {
if (seen.has(vote.questionId)) {
return actionError("Duplicate vote for the same question");
}
seen.add(vote.questionId);
const question = questionById.get(vote.questionId);
if (!question || question.pollId !== poll.id) {
return actionError("Invalid question for this poll");
}
const answer = vote.answer.trim();
if (!answer) return actionError("Answer is required");
if (question.type === "text") {
if (answer.length > 500) {
return actionError("Answer is too long");
}
} else {
const options = parsePollOptions(question.options);
if (question.type === "multiple") {
const selected = answer
.split("\n")
.map((a) => a.trim())
.filter(Boolean);
if (selected.length === 0) {
return actionError("Select at least one option");
}
if (selected.some((a) => !options.includes(a))) {
return actionError("Invalid option selected");
}
} else if (!options.includes(answer)) {
return actionError("Invalid option selected");
}
}
const existing = await prisma.websitePollVote.findUnique({
where: {
questionId_userId: {
questionId: vote.questionId,
userId,
},
},
});
if (existing) {
return actionError("You have already voted on this poll");
}
}
await prisma.$transaction(
ctx.data.votes.map((vote) =>
prisma.websitePollVote.create({
data: {
questionId: vote.questionId,
userId,
answer: vote.answer.trim(),
},
}),
),
);
revalidatePath("/polls");
revalidatePath(`/polls/${poll.id}`);
return actionOk({ pollId: poll.id });
},
);
+39 -14
View File
@@ -1,8 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// Column bounds from prisma/schema.prisma (radio_applications):
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
@@ -10,6 +12,23 @@ const NAME_MAX = 255;
const TEXT_MAX = 5000;
const STYLE_MAX = 5000;
type ApplyOutcome = "submitted" | "invalid" | "ratelimit" | "error";
function applyRedirect(outcome: ApplyOutcome): never {
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
redirect(`/radio/apply?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
function str(form: FormData, key: string, max: number): string {
return String(form.get(key) ?? "")
.normalize("NFC")
@@ -19,17 +38,21 @@ function str(form: FormData, key: string, max: number): string {
/**
* Submit a radio DJ application.
*
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
* never taken from the submitted FormData, so a crafted form cannot file an
* application on behalf of another account. radio_applications.user_id is an
* UnsignedBigInt, hence the BigInt() coercion.
*/
export async function applyDj(formData: FormData): Promise<void> {
let outcome: ApplyOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`radio-apply:${userId}`, 2, 300_000)).ok) {
outcome = "ratelimit";
} else {
const realName = str(formData, "realName", NAME_MAX);
const availability = str(formData, "availability", TEXT_MAX);
const motivation = str(formData, "motivation", TEXT_MAX);
@@ -39,12 +62,10 @@ export async function applyDj(formData: FormData): Promise<void> {
const ageRaw = Number(formData.get("age"));
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
// Required fields per the schema (NOT NULL): real_name, age, availability,
// motivation. experience + music_style are nullable.
if (!realName || !availability || !motivation || age <= 0) return;
if (!realName || !availability || !motivation || age <= 0) {
outcome = "invalid";
} else {
const now = new Date();
try {
await prisma.radioApplications.create({
data: {
userId: BigInt(userId),
@@ -59,10 +80,14 @@ export async function applyDj(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
// DB unavailable or duplicate — fail soft; nothing to persist.
return;
outcome = "submitted";
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/radio/apply");
applyRedirect(outcome);
}
+39 -6
View File
@@ -1,17 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
const SONG_MAX = 255;
const ARTIST_MAX = 255;
type RequestOutcome = "posted" | "empty" | "invalid" | "ratelimit" | "error";
function requestsRedirect(outcome: RequestOutcome): never {
if (outcome === "posted") redirect("/radio/requests?posted=1");
redirect(`/radio/requests?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
export async function submitRequest(formData: FormData): Promise<void> {
let outcome: RequestOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const songTitle = String(formData.get("songTitle") ?? "")
.normalize("NFC")
.trim()
@@ -20,10 +48,10 @@ export async function submitRequest(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, ARTIST_MAX);
if (!songTitle && !artist) return;
if (!songTitle && !artist) {
outcome = "empty";
} else {
const now = new Date();
try {
await prisma.radioSongRequests.create({
data: {
userId: BigInt(userId),
@@ -34,9 +62,14 @@ export async function submitRequest(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
return;
outcome = "posted";
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/radio/requests");
requestsRedirect(outcome);
}
+45 -11
View File
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -11,6 +12,23 @@ const shoutSchema = z.object({
message: z.string().min(1, "Message is required").max(255),
});
type ShoutOutcome = "posted" | "invalid" | "moderated" | "ratelimit" | "error";
function shoutsRedirect(outcome: ShoutOutcome): never {
if (outcome === "posted") redirect("/radio/shouts?posted=1");
redirect(`/radio/shouts?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Post a radio shout.
*
@@ -18,15 +36,24 @@ const shoutSchema = z.object({
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
* session id is widened to BigInt for the insert.
*
* Errors redirect back with a machine-readable ?error= code; success redirects
* with ?posted=1.
*/
export async function postShout(formData: FormData): Promise<void> {
let outcome: ShoutOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) {
outcome = "ratelimit";
} else {
const raw = {
message: String(formData.get("message") ?? "")
.normalize("NFC")
@@ -35,19 +62,21 @@ export async function postShout(formData: FormData): Promise<void> {
};
const parsed = shoutSchema.safeParse(raw);
if (!parsed.success) return;
if (!parsed.success) {
outcome = "invalid";
} else {
const { message } = parsed.data;
// Moderation check
let moderated = false;
try {
await moderateOrThrow(message);
} catch {
return;
moderated = true;
outcome = "moderated";
}
if (!moderated) {
const now = new Date();
try {
await prisma.radioShouts.create({
data: {
userId: BigInt(userId),
@@ -56,10 +85,15 @@ export async function postShout(formData: FormData): Promise<void> {
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "posted";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/radio/shouts");
shoutsRedirect(outcome);
}
+11 -2
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp } from "@/lib/rate-limit";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
@@ -40,6 +40,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
| "not_enough"
| "no_referrals"
| "bad_config"
| "ratelimit"
| "error" = "error";
try {
@@ -53,6 +54,10 @@ export async function claimReferral(_formData: FormData): Promise<void> {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
// 5 referrals needed, 30 diamonds reward.
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
@@ -74,7 +79,10 @@ export async function claimReferral(_formData: FormData): Promise<void> {
.findFirst({
where: {
key: {
in: ["referral_reward_currency_type", "referral_reward_currency"],
in: [
"referral_reward_currency_type",
"referral_reward_currency",
],
},
},
select: { value: true },
@@ -145,6 +153,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
outcome = "claimed";
}
}
} catch (err) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
// navigation actually happens instead of being treated as a failure.
+5 -8
View File
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { sendVerification } from "@/actions/email-verify";
import { hashPassword } from "@/lib/auth/password";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
@@ -54,7 +55,7 @@ export async function register(
const parsed = registerSchema.safeParse(raw);
if (!parsed.success) {
return parsed.error.errors[0]?.message ?? "Invalid input";
return parsed.error.issues[0]?.message ?? "Invalid input";
}
const { username, mail, password, look } = parsed.data;
@@ -100,6 +101,7 @@ export async function register(
});
if (existing) return "That username is already taken";
} catch {
logger.warn("Username uniqueness check failed during registration");
return "Registration is temporarily unavailable";
}
@@ -122,18 +124,13 @@ export async function register(
try {
await sendVerification(mail);
} catch {
// No-op: account is created; user can request a new link later.
logger.warn("Failed to send verification email after registration");
}
}
} catch {
logger.warn("Account creation failed");
return "Could not create the account (is the username unique?)";
}
if (hasEmail) {
redirect("/login?registered=1");
} else {
const { signIn } = await import("@/lib/auth");
await signIn("credentials", { username, password, redirect: false });
redirect("/verify?method=discord");
}
}
+1 -1
View File
@@ -3,9 +3,9 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { resolveMediaPath } from "@/lib/media-storage";
const FAVICON_DIR = resolveMediaPath("favicon");
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
+1 -54
View File
@@ -3,9 +3,9 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { resolveMediaPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { resolveMediaPath } from "@/lib/media-storage";
const MEDIA_DIR = resolveMediaPath("logo");
@@ -58,56 +58,3 @@ export async function saveLogo(
};
}
}
export async function saveLogoFromUrl(
gifUrl: string,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const res = await fetch(gifUrl);
if (!res.ok)
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
const contentType = res.headers.get("content-type") ?? "image/gif";
const buffer = Buffer.from(await res.arrayBuffer());
const ext =
contentType === "image/png"
? "png"
: contentType === "image/gif"
? "gif"
: contentType === "image/jpeg"
? "jpg"
: contentType === "image/webp"
? "webp"
: "gif";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = MEDIA_DIR;
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({
where: { key: "cms_logo" },
update: { value: url },
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
});
siteSettings.reload();
revalidatePath("/", "layout");
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { auth, signOut } from "@/lib/auth";
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
/**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* revoke personal access tokens, then end the current browser session too.
*/
export async function signOutEverywhere(): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
await signOut({ redirectTo: "/login" });
return;
}
try {
await prisma.user.update({
where: { id: userId },
data: { websiteJwtVersion: { increment: 1 } },
});
await invalidateJwtVersionCache(userId);
} catch (err) {
logger.warn("Failed to bump JWT version during sign-out-everywhere", {
userId,
error: err instanceof Error ? err.message : "Unknown",
});
}
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
try {
await prisma.personalAccessTokens.deleteMany({
where: personalTokenScope(userId),
});
} catch (err) {
logger.warn(
"Failed to revoke personal access tokens during sign-out-everywhere",
{
userId,
error: err instanceof Error ? err.message : "Unknown",
},
);
}
await signOut({ redirectTo: "/login?signedOutAll=1" });
}
+191
View File
@@ -0,0 +1,191 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { creditsPerUnit } from "@/lib/services/paypal";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
/**
* Credits charged for a package row. AtomCMS stores `costs` in cents (USD display);
* we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits.
*/
function creditPriceFromCosts(costs: number): number {
const rate = creditsPerUnit();
const dollars = costs < 100 ? 1 : costs / 100;
return Math.max(1, Math.floor(dollars * rate));
}
function parseBadgeCodes(raw: string | null | undefined): string[] {
if (!raw?.trim()) return [];
return raw
.split(/[,;]+/)
.map((s) => s.trim())
.filter((s) => s.length > 0 && s.length <= 32);
}
type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error";
function shopRedirect(
categoryId: string,
outcome: BuyOutcome,
articleName?: string,
): never {
const params = new URLSearchParams();
if (categoryId) params.set("category", categoryId);
if (outcome === "bought") {
params.set("bought", "1");
if (articleName) params.set("package", articleName);
} else {
params.set("error", outcome);
}
const qs = params.toString();
redirect(qs ? `/shop?${qs}` : "/shop");
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Purchase a website shop package with in-game credits (top up via /shop/topup first).
* The buyer id is always taken from the session, never from FormData.
*/
export async function buyShopArticle(formData: FormData): Promise<void> {
const categoryId = String(formData.get("categoryId") ?? "")
.normalize("NFC")
.trim();
const safeCategory = /^\d+$/.test(categoryId) ? categoryId : "";
let outcome: BuyOutcome = "error";
let packageName = "";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const rawId = String(formData.get("articleId") ?? "")
.normalize("NFC")
.trim();
if (!/^\d+$/.test(rawId)) {
outcome = "invalid";
} else {
const article = await prisma.websiteShopArticles.findUnique({
where: { id: BigInt(rawId) },
select: {
id: true,
name: true,
costs: true,
credits: true,
duckets: true,
diamonds: true,
badges: true,
giveRank: true,
},
});
if (!article) {
outcome = "invalid";
} else {
packageName = article.name;
const price = creditPriceFromCosts(article.costs);
const buyer = await prisma.user.findUnique({
where: { id: userId },
select: { credits: true, rank: true },
});
if (!buyer || buyer.credits < price) {
outcome = "credits";
} else {
const badgeCodes = parseBadgeCodes(article.badges);
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
if (price > 0) {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
});
}
if (
article.giveRank != null &&
article.giveRank > 0 &&
article.giveRank > buyer.rank
) {
await tx.user.update({
where: { id: userId },
data: { rank: article.giveRank },
});
}
for (const code of badgeCodes) {
const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const max = await tx.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
}
});
await sendCurrency(
{ rcon, db: prisma },
userId,
"credits",
article.credits,
);
await sendCurrency(
{ rcon, db: prisma },
userId,
"duckets",
article.duckets,
);
await sendCurrency(
{ rcon, db: prisma },
userId,
"diamonds",
article.diamonds,
);
for (const code of badgeCodes) {
await rcon.giveBadge(userId, code).catch(() => {});
}
outcome = "bought";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
revalidatePath("/shop");
shopRedirect(safeCategory, outcome, packageName || undefined);
}
+249 -32
View File
@@ -1,8 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// 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
@@ -10,6 +12,72 @@ import { prisma } from "@/lib/prisma";
const SUBJECT_MAX = 255;
const MESSAGE_MAX = 10000;
type FriendRequestOutcome =
| "sent"
| "self"
| "invalid"
| "already_friends"
| "already_pending"
| "incoming_pending"
| "ratelimit"
| "error";
type ThreadOutcome = "posted" | "invalid" | "not_found" | "ratelimit" | "error";
type ReplyOutcome =
| "replied"
| "invalid"
| "not_found"
| "locked"
| "ratelimit"
| "error";
function profileRedirect(
username: string,
outcome: FriendRequestOutcome,
): never {
const path = username ? `/u/${encodeURIComponent(username)}` : "/";
if (outcome === "sent") redirect(`${path}?friend=sent`);
redirect(`${path}?error=${outcome}`);
}
function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
const base =
Number.isInteger(guildId) && guildId > 0
? `/guilds/${guildId}/forum`
: "/guilds";
if (outcome === "posted") redirect(`${base}?posted=1`);
redirect(`${base}/new?error=${outcome}`);
}
function threadReplyRedirect(
guildId: number,
threadId: number,
outcome: ReplyOutcome,
): never {
if (
!Number.isInteger(guildId) ||
guildId <= 0 ||
!Number.isInteger(threadId) ||
threadId <= 0
) {
redirect("/guilds");
}
const base = `/guilds/${guildId}/forum/${threadId}`;
if (outcome === "replied") redirect(`${base}?replied=1`);
redirect(`${base}?error=${outcome}`);
}
function isNextRedirect(e: unknown): boolean {
return (
!!e &&
typeof e === "object" &&
"digest" in e &&
typeof (e as { digest?: unknown }).digest === "string" &&
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
);
}
/**
* Send a friend request to another user.
*
@@ -19,25 +87,46 @@ const MESSAGE_MAX = 10000;
*
* Writes into messenger_friendrequests (userFromId = requester, userToId =
* target). The emulator surfaces the pending request in the in-game messenger.
*
* Errors redirect back to the profile with a machine-readable ?error= code;
* success redirects with ?friend=sent. redirect() is called OUTSIDE the
* try/catch so its control-flow throw is never swallowed.
*/
export async function sendFriendRequest(formData: FormData): Promise<void> {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) return;
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) return;
// Can't befriend yourself.
if (toId === fromId) return;
let outcome: FriendRequestOutcome = "error";
try {
const session = await auth();
const fromId = Number(session?.user?.id);
if (!Number.isInteger(fromId) || fromId <= 0) {
redirect("/login");
}
await clientIp();
if (!(await rateLimit(`friend:${fromId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const toId = Number(formData.get("userId"));
if (!Number.isInteger(toId) || toId <= 0) {
outcome = "invalid";
} else if (toId === fromId) {
outcome = "self";
} else {
// Guard against duplicate pending requests and already-existing friendships.
const [existingRequest, existingFriendship] = await Promise.all([
const [outgoingRequest, incomingRequest, existingFriendship] =
await Promise.all([
prisma.messengerFriendrequests.findFirst({
where: { userFromId: fromId, userToId: toId },
select: { id: true },
}),
prisma.messengerFriendrequests.findFirst({
where: { userFromId: toId, userToId: fromId },
select: { id: true },
}),
prisma.messengerFriendships.findFirst({
where: {
OR: [
@@ -49,22 +138,30 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
}),
]);
if (existingRequest || existingFriendship) return;
if (existingFriendship) {
outcome = "already_friends";
} else if (outgoingRequest) {
outcome = "already_pending";
} else if (incomingRequest) {
// They already asked you — respond from Messages instead of
// creating a duplicate reverse row.
outcome = "incoming_pending";
} else {
await prisma.messengerFriendrequests.create({
data: { userFromId: fromId, userToId: toId },
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "sent";
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
// Optional: revalidate the target profile if a username was supplied, purely
// to refresh any request-state UI rendered there.
const username = String(formData.get("username") ?? "")
.normalize("NFC")
.trim();
if (username) revalidatePath(`/u/${username}`);
revalidatePath("/messages");
profileRedirect(username, outcome);
}
/**
@@ -78,15 +175,28 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
* plus the opening post stored as the first comment (guilds_forums_comments).
* We create both in a transaction so the thread always has its first post, then
* stamp posts_count = 1 to match the emulator's bookkeeping.
*
* Errors redirect back to the new-thread form with ?error=; success redirects
* to the forum with ?posted=1.
*/
export async function postThread(formData: FormData): Promise<void> {
const guildId = Number(formData.get("guildId"));
let outcome: ThreadOutcome = "error";
try {
const session = await auth();
const openerId = Number(session?.user?.id);
if (!Number.isInteger(openerId) || openerId <= 0) return;
const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return;
if (!Number.isInteger(openerId) || openerId <= 0) {
redirect("/login");
}
if (!Number.isInteger(guildId) || guildId <= 0) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`forum:${openerId}`, 3, 60_000)).ok) {
outcome = "ratelimit";
} else {
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
@@ -95,18 +205,18 @@ export async function postThread(formData: FormData): Promise<void> {
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) return;
if (!subject || !message) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
try {
// Confirm the guild exists (and has a forum) before opening a thread.
const guild = await prisma.guilds.findUnique({
where: { id: guildId },
select: { id: true },
});
if (!guild) return;
if (!guild) {
outcome = "not_found";
} else {
await prisma.$transaction(async (tx) => {
const thread = await tx.guildsForumsThreads.create({
data: {
@@ -135,10 +245,117 @@ export async function postThread(formData: FormData): Promise<void> {
},
});
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
outcome = "posted";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (Number.isInteger(guildId) && guildId > 0) {
revalidatePath(`/guilds/${guildId}/forum`);
}
threadRedirect(guildId, outcome);
}
/**
* Reply to an existing guild forum thread.
*
* The AUTHOR (user_id) is re-read from the session via auth() and is never
* trusted from the submitted FormData. guildId, threadId, and message come
* from the form.
*
* Appends a row to guilds_forums_comments and bumps the thread's posts_count
* and updated_at to match emulator bookkeeping. Locked threads reject replies.
*/
export async function replyToThread(formData: FormData): Promise<void> {
const guildId = Number(formData.get("guildId"));
const threadId = Number(formData.get("threadId"));
let outcome: ReplyOutcome = "error";
try {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
redirect("/login");
}
if (
!Number.isInteger(guildId) ||
guildId <= 0 ||
!Number.isInteger(threadId) ||
threadId <= 0
) {
outcome = "invalid";
} else {
await clientIp();
if (!(await rateLimit(`forum-reply:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) {
outcome = "invalid";
} else {
const now = Math.floor(Date.now() / 1000);
const thread = await prisma.guildsForumsThreads.findFirst({
where: { id: threadId, guildId, state: 0 },
select: {
id: true,
locked: true,
postsCount: true,
},
});
if (!thread) {
outcome = "not_found";
} else if (thread.locked) {
outcome = "locked";
} else {
await prisma.$transaction(async (tx) => {
await tx.guildsForumsComments.create({
data: {
threadId: thread.id,
userId,
message,
createdAt: now,
state: 0,
adminId: 0,
},
});
await tx.guildsForumsThreads.update({
where: { id: thread.id },
data: {
postsCount: (thread.postsCount ?? 0) + 1,
updatedAt: now,
},
});
});
outcome = "replied";
}
}
}
}
} catch (e) {
if (isNextRedirect(e)) throw e;
outcome = "error";
}
if (
Number.isInteger(guildId) &&
guildId > 0 &&
Number.isInteger(threadId) &&
threadId > 0
) {
revalidatePath(`/guilds/${guildId}/forum`);
revalidatePath(`/guilds/${guildId}/forum/${threadId}`);
}
threadReplyRedirect(guildId, threadId, outcome);
}
+34 -7
View File
@@ -17,7 +17,12 @@ import {
// ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction(
{ schema: createTicketSchema },
{
schema: createTicketSchema,
rateLimitKey: "ticket-create",
rateLimitMax: 5,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.create({
data: {
@@ -49,7 +54,12 @@ export const createTicket = authAction(
);
export const userReplyTicket = authAction(
{ schema: replyTicketSchema },
{
schema: replyTicketSchema,
rateLimitKey: "ticket-reply",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
);
export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) },
{
schema: replyTicketSchema.pick({ ticketId: true }),
rateLimitKey: "ticket-close",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -106,7 +121,10 @@ export const closeTicketByUser = authAction(
// ── Admin actions ────────────────────────────────────────────────────
export const adminReplyTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: replyTicketSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -146,7 +164,10 @@ export const adminReplyTicket = adminAction(
);
export const updateTicketStatus = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketStatusSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -181,7 +202,10 @@ export const updateTicketStatus = adminAction(
);
export const assignTicket = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: assignTicketSchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -211,7 +235,10 @@ export const assignTicket = adminAction(
);
export const updateTicketPriority = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketPrioritySchema,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
+5 -3
View File
@@ -1,9 +1,9 @@
"use server";
import crypto from "node:crypto";
import { hash } from "bcryptjs";
import { z } from "zod";
import { Prisma } from "@/generated/prisma/client";
import { hashPassword } from "@/lib/auth/password";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
@@ -30,7 +30,7 @@ export const createUser = adminAction(
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const hashedPassword = await hash(password, 12);
const hashedPassword = await hashPassword(password);
const now = Math.floor(Date.now() / 1000);
try {
@@ -304,7 +304,7 @@ export const resetPassword = adminAction(
.randomBytes(12)
.toString("base64url")
.slice(0, 16);
const hashed = await hash(newPassword, 10);
const hashed = await hashPassword(newPassword);
await prisma.user.update({
where: { id: ctx.data.userId },
@@ -388,6 +388,7 @@ export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success)
throw new ActionError("Failed to mute. Is the emulator running?");
@@ -440,6 +441,7 @@ export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success)
throw new ActionError("Failed to send credits. Is the emulator running?");
+9
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
@@ -40,6 +41,14 @@ export async function redeem(
};
}
await clientIp();
if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) {
return {
ok: false,
message: "You're redeeming too fast. Please wait a moment and try again.",
};
}
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim();
+57 -9
View File
@@ -1,29 +1,60 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyStaffPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function ApplyStaffPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
function formatDeadline(d: Date | null | undefined): string {
if (!d) return t("noDeadline");
return d.toISOString().slice(0, 16).replace("T", " ");
}
const hotelName = await resolveHotelName();
// ── Open positions ──────────────────────────────────────────
// Each query is isolated so a DB hiccup degrades that section to empty
@@ -53,6 +84,17 @@ export default async function ApplyStaffPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🛡️"
title={t("title", { hotel: hotelName })}
@@ -103,7 +145,13 @@ export default async function ApplyStaffPage() {
<div className="content-card-body">
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
{t("deadline", { date: formatDeadline(position.applyTo) })}
{t("deadline", {
date: formatDate(
position.applyTo,
"datetime",
t("noDeadline"),
),
})}
</p>
{alreadyApplied ? (
+49 -3
View File
@@ -1,24 +1,59 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { resolveHotelName } from "@/lib/hotel-name";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyTeamPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function ApplyTeamPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const hotelName = await resolveHotelName();
// ── Teams open for application ──────────────────────────────
// The Prisma slice exposes teams directly (no separate team-position table
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🤝"
title={t("title", { hotel: hotelName })}
+10 -6
View File
@@ -1,6 +1,7 @@
import { getTranslations } from "next-intl/server";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { activeBanWhere, unixNow } from "@/lib/bans";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
@@ -12,15 +13,19 @@ export default async function BannedPage() {
const session = await auth();
let reason = "";
let expire = 0;
let hasBan = false;
if (session?.user?.id) {
try {
const now = Math.floor(Date.now() / 1000);
const ban = await prisma.ban.findFirst({
where: { userId: Number(session.user.id), banExpire: { gt: now } },
orderBy: { banExpire: "desc" },
where: {
userId: Number(session.user.id),
...activeBanWhere(unixNow()),
},
orderBy: { timestamp: "desc" },
select: { banReason: true, banExpire: true },
});
if (ban) {
hasBan = true;
reason = ban.banReason;
expire = ban.banExpire;
}
@@ -29,10 +34,9 @@ export default async function BannedPage() {
}
}
const expiryText =
expire === 0
const expiryText = !hasBan
? ""
: expire > FAR_FUTURE
: expire === 0 || expire > FAR_FUTURE
? t("permanent")
: t("expires", {
date: new Date(expire * 1000)
+2
View File
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
const ERROR_NOTE: Record<string, string> = {
invalid: "That badge is no longer available.",
credits: "You don't have enough credits to buy this badge.",
ratelimit: "You're purchasing too fast. Please wait a moment and try again.",
fail: "Something went wrong. Please try again.",
};
function getErrorNote(error: string): string {
if (error === "invalid") return ERROR_NOTE.invalid;
if (error === "credits") return ERROR_NOTE.credits;
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
return ERROR_NOTE.fail;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { Home, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { ErrorScreen } from "@/components/error-screen";
/**
* Site route-segment error boundary. Renders inside the site shell layout.
* Never shows the raw error to the user.
*/
export default function SiteErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("pages.error");
useEffect(() => {
console.error(error);
Sentry.captureException(error);
}, [error]);
return (
<ErrorScreen
code={t("code")}
title={t("title")}
subtitle={t("subtitle")}
body={t("body")}
actions={
<>
<button
type="button"
className="btn btn-primary"
onClick={() => reset()}
>
<RefreshCw className="error-screen-btn-icon" aria-hidden />
{t("tryAgain")}
</button>
<Link className="btn btn-outline" href="/">
<Home className="error-screen-btn-icon" aria-hidden />
{t("backHome")}
</Link>
</>
}
footer={
error.digest ? (
<p className="error-screen-digest">
{t("reference", { digest: error.digest })}
</p>
) : null
}
/>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useTranslations } from "next-intl";
import { registerForEvent } from "@/actions/events";
import { useServerAction } from "@/hooks/use-server-action";
export function EventRegisterButton({
eventId,
disabled,
disabledReason,
}: {
eventId: number;
disabled?: boolean;
disabledReason?: string;
}) {
const t = useTranslations("pages.events");
const { run, isPending } = useServerAction();
if (disabled) {
return (
<p className="muted" style={{ margin: 0 }}>
{disabledReason ?? t("cannotRegister")}
</p>
);
}
return (
<button
type="button"
className="btn btn-primary"
disabled={isPending}
onClick={() =>
run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"),
errorMessage: t("registerError"),
})
}
>
{isPending ? t("registering") : t("register")}
</button>
);
}
+209
View File
@@ -0,0 +1,209 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
import { EventRegisterButton } from "./event-register-button";
export const dynamic = "force-dynamic";
function parseEventId(slugOrId: string): number | null {
if (/^\d+$/.test(slugOrId)) return Number.parseInt(slugOrId, 10);
const match = slugOrId.match(/-(\d+)$/);
if (match) return Number.parseInt(match[1], 10);
return null;
}
export default async function EventDetailPage({
params,
}: {
params: Promise<{ slugOrId: string }>;
}) {
const { slugOrId } = await params;
const eventId = parseEventId(slugOrId);
if (eventId == null || !Number.isFinite(eventId)) notFound();
const t = await getTranslations("pages.events");
const session = await auth();
const userId = Number(session?.user?.id);
const event = await prisma.websiteEvent
.findUnique({
where: { id: eventId },
include: {
type: true,
prizes: { orderBy: { position: "asc" } },
winners: { orderBy: { position: "asc" } },
_count: { select: { registrations: true } },
},
})
.catch(() => null);
if (
!event ||
(event.status !== "published" && event.status !== "completed")
) {
notFound();
}
const alreadyRegistered =
Number.isInteger(userId) && userId > 0
? Boolean(
await prisma.websiteEventRegistration
.findUnique({
where: { eventId_userId: { eventId: event.id, userId } },
})
.catch(() => null),
)
: false;
const winnerIds = event.winners.map((w) => w.userId);
const winners =
winnerIds.length > 0
? await prisma.user
.findMany({
where: { id: { in: winnerIds } },
select: { id: true, username: true },
})
.catch(() => [])
: [];
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
const isFull =
event.maxPlayers != null && event._count.registrations >= event.maxPlayers;
const hasEnded =
event.status === "completed" ||
(event.endsAt != null && event.endsAt.getTime() < Date.now());
const canRegister =
event.status === "published" && !hasEnded && !isFull && !alreadyRegistered;
let disabledReason: string | undefined;
if (!session?.user) disabledReason = t("loginToRegister");
else if (alreadyRegistered) disabledReason = t("alreadyRegistered");
else if (hasEnded) disabledReason = t("eventEnded");
else if (isFull) disabledReason = t("eventFull");
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<p className="muted" style={{ margin: 0 }}>
<Link href="/events">{t("back")}</Link>
</p>
{event.image ? (
<div
style={{
position: "relative",
width: "100%",
aspectRatio: "21/9",
borderRadius: "0.75rem",
overflow: "hidden",
}}
>
<Image
src={event.image}
alt=""
fill
style={{ objectFit: "cover" }}
unoptimized
priority
/>
</div>
) : null}
<ContentCard
icon="📅"
title={event.title}
subtitle={`${event.type.name} · ${formatDate(event.startsAt, "datetime", "")}`}
>
<div style={{ display: "grid", gap: "0.75rem" }}>
<p style={{ margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>
{event.description}
</p>
<table style={{ width: "100%" }}>
<tbody>
<tr>
<th style={{ width: 140, textAlign: "left" }}>{t("when")}</th>
<td>
{formatDate(event.startsAt, "datetime", "")}
{event.endsAt
? ` — ${formatDate(event.endsAt, "datetime", "")}`
: ""}
</td>
</tr>
<tr>
<th style={{ textAlign: "left" }}>{t("status")}</th>
<td>
{event.status === "completed"
? t("statusCompleted")
: t("statusPublished")}
</td>
</tr>
<tr>
<th style={{ textAlign: "left" }}>{t("registrations")}</th>
<td>
{event.maxPlayers != null
? t("spots", {
count: event._count.registrations,
max: event.maxPlayers,
})
: t("registered", {
count: event._count.registrations,
})}
</td>
</tr>
</tbody>
</table>
</div>
</ContentCard>
<ContentCard
icon="✍️"
title={t("registerTitle")}
subtitle={t("registerSubtitle")}
>
{session?.user && canRegister ? (
<EventRegisterButton eventId={event.id} />
) : (
<EmptyState icon={session?.user ? "ℹ️" : "🔒"}>
{disabledReason ?? t("cannotRegister")}
</EmptyState>
)}
</ContentCard>
{event.prizes.length > 0 ? (
<ContentCard icon="🎁" title={t("prizesTitle")}>
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
{event.prizes.map((prize) => (
<li key={prize.id} style={{ marginBottom: "0.35rem" }}>
<strong>#{prize.position}</strong>
{prize.description
? ` — ${prize.description}`
: ` — ${prize.prizeType}`}
{prize.credits > 0 ? ` · ${prize.credits} credits` : ""}
{prize.pixels > 0 ? ` · ${prize.pixels} pixels` : ""}
{prize.points > 0 ? ` · ${prize.points} points` : ""}
{prize.badgeCode ? ` · badge ${prize.badgeCode}` : ""}
</li>
))}
</ul>
</ContentCard>
) : null}
{event.winners.length > 0 ? (
<ContentCard icon="🏆" title={t("winnersTitle")}>
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
{event.winners.map((w) => (
<li key={w.id}>
#{w.position} —{" "}
{winnerName.get(w.userId) ?? t("unknownUser", { id: w.userId })}
</li>
))}
</ul>
</ContentCard>
) : null}
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+139
View File
@@ -0,0 +1,139 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { excerpt, slugify } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { prisma } from "@/lib/prisma";
export const revalidate = 60;
export const metadata = { title: "Events" };
export default async function EventsPage() {
const t = await getTranslations("pages.events");
const events = await prisma.websiteEvent
.findMany({
where: { status: { in: ["published", "completed"] } },
orderBy: { startsAt: "desc" },
take: 50,
include: {
type: { select: { name: true, color: true, slug: true } },
_count: { select: { registrations: true } },
},
})
.catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="📅" title={t("title")} subtitle={t("subtitle")} />
<ContentCard padded={events.length === 0}>
{events.length === 0 ? (
<EmptyState icon="📅">{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{events.map((event) => {
const href = `/events/${slugify(event.title)}-${event.id}`;
const spots =
event.maxPlayers != null
? t("spots", {
count: event._count.registrations,
max: event.maxPlayers,
})
: t("registered", { count: event._count.registrations });
return (
<Link
key={event.id}
href={href}
className="card hover"
style={{
display: "flex",
flexDirection: "column",
textDecoration: "none",
padding: 0,
overflow: "hidden",
color: "inherit",
}}
>
{event.image ? (
<div
style={{
position: "relative",
width: "100%",
aspectRatio: "16/9",
}}
>
<Image
src={event.image}
alt=""
fill
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
<div
style={{
width: "100%",
aspectRatio: "16/9",
backgroundColor:
event.type.color ||
"color-mix(in srgb, var(--color-primary) 10%, var(--color-navbar))",
}}
/>
)}
<div
style={{
padding: "1rem 1.1rem",
flex: 1,
display: "flex",
flexDirection: "column",
gap: "0.35rem",
}}
>
<p
className="muted"
style={{ margin: 0, fontSize: "0.75rem" }}
>
{event.type.name}
{event.status === "completed"
? ` · ${t("statusCompleted")}`
: ""}
</p>
<h3 style={{ margin: 0, fontSize: "1.05rem" }}>
{event.title}
</h3>
<p
className="muted"
style={{ margin: 0, fontSize: "0.85rem" }}
>
{formatDate(event.startsAt, "datetime", "")}
</p>
<p
style={{
margin: 0,
fontSize: "0.88rem",
lineHeight: 1.5,
}}
>
{excerpt(event.description, 120)}
</p>
<p
className="muted"
style={{ margin: "auto 0 0", fontSize: "0.8rem" }}
>
{spots}
</p>
</div>
</Link>
);
})}
</div>
)}
</ContentCard>
</main>
);
}
+26 -2
View File
@@ -1,15 +1,20 @@
import { headers } from "next/headers";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requestReset } from "@/actions/password-reset";
import { CaptchaWidget } from "@/components/auth/captcha-widget";
import { ContentCard } from "@/components/public/ui";
import { captchaConfig } from "@/lib/services/captcha";
export default async function ForgotPage({
searchParams,
}: {
searchParams: Promise<{ sent?: string }>;
searchParams: Promise<{ sent?: string; error?: string }>;
}) {
const t = await getTranslations("pages.forgot");
const { sent } = await searchParams;
const { sent, error } = await searchParams;
const cfg = await captchaConfig();
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
@@ -30,6 +35,25 @@ export default async function ForgotPage({
autoComplete="email"
required
/>
<CaptchaWidget
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
{error === "captcha" ? (
<p
style={{
color: "var(--color-danger)",
textAlign: "center",
margin: 0,
}}
>
{t("errorCaptcha")}
</p>
) : null}
<button type="submit" className="btn btn-primary">
{t("sendResetLink")}
</button>
+15
View File
@@ -0,0 +1,15 @@
export default function FriendsLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more