Compare commits
184
Commits
31ce548c65
..
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a297884a | ||
|
|
01196b0843 | ||
|
|
48bdd6f2e6 | ||
|
|
2d003a2101 | ||
|
|
2f5d2b6e09 | ||
|
|
f31afb5318 | ||
|
|
b12eaeef82 | ||
|
|
c6905d3865 | ||
|
|
8876b38f0a | ||
|
|
02280d0f42 | ||
|
|
f43bb234ab | ||
|
|
dacc4cadfd | ||
|
|
65f118c918 | ||
|
|
cc458332d7 | ||
|
|
3f43a62243 | ||
|
|
5f98b949ad | ||
|
|
cab4280e63 | ||
|
|
016f35bab3 | ||
|
|
d97db2a3e7 | ||
|
|
ff47fd31e5 | ||
|
|
fa9b8c83d2 | ||
|
|
69506b70e0 | ||
|
|
b897cff901 | ||
|
|
edc8fc6584 | ||
|
|
7df37728aa | ||
|
|
af9f11d934 | ||
|
|
14b9e77c1a | ||
|
|
423a33200e | ||
|
|
0f5c190ab1 | ||
|
|
4bb908ecf7 | ||
|
|
7cc88287b8 | ||
|
|
de95412a6c | ||
|
|
46e9f61b35 | ||
|
|
17847545dd | ||
|
|
b922f6d49f | ||
|
|
8c2fe603d1 | ||
|
|
1e0dd183b1 | ||
|
|
ea71a574a4 | ||
|
|
b77dbb65f6 | ||
|
|
7f7e11adcf | ||
|
|
86d15d0d92 | ||
|
|
e336ac3c66 | ||
|
|
623cced64e | ||
|
|
e464fac102 | ||
|
|
2a727b2a71 | ||
|
|
f072a28aee | ||
|
|
d010344423 | ||
|
|
54c155bcef | ||
|
|
0cbd4ca858 | ||
|
|
0869b82f5f | ||
|
|
29cd09e458 | ||
|
|
04743fde6e | ||
|
|
3284e5a915 | ||
|
|
a906d671fb | ||
|
|
a847ecae04 | ||
|
|
2818d2eb20 | ||
|
|
7253bb61be | ||
|
|
4e2c4ccf07 | ||
|
|
2c0478bfc0 | ||
|
|
960fea3549 | ||
|
|
78b1982145 | ||
|
|
ac3c42f308 | ||
|
|
2e9db75eca | ||
|
|
04b9844f0f | ||
|
|
c670227ad4 | ||
|
|
1acace49d0 | ||
|
|
12e4a07e48 | ||
|
|
fc42a1c138 | ||
|
|
fee71fe7dc | ||
|
|
e8ade7afa3 | ||
|
|
3773c6cb29 | ||
|
|
c432d4e95d | ||
|
|
b37b6bc2b9 | ||
|
|
5fa342018d | ||
|
|
7f8d083763 | ||
|
|
f7f6e09174 | ||
|
|
a9f1f4f99d | ||
|
|
f6ee99801d | ||
|
|
7fc7412f18 | ||
|
|
dae381e07c | ||
|
|
7dbca4117c | ||
|
|
331a18e9c7 | ||
|
|
f7551166c5 | ||
|
|
b6ba554386 | ||
|
|
aabf14aaf9 | ||
|
|
9226558aa0 | ||
|
|
d9e95c823a | ||
|
|
a058a6e923 | ||
|
|
5913328a53 | ||
|
|
71a3b0a400 | ||
|
|
eb603b50a9 | ||
|
|
b9c09f58d3 | ||
|
|
83356b9a9c | ||
|
|
a82cf7d2a1 | ||
|
|
4457117fa9 | ||
|
|
24c4279a7e | ||
|
|
36d9046a8d | ||
|
|
ac16df98c3 | ||
|
|
255c09b9fd | ||
|
|
ce6e8235cc | ||
|
|
75cace41ea | ||
|
|
084cca6ea4 | ||
|
|
a384c9a8bb | ||
|
|
1e77398a7c | ||
|
|
5be5405bc9 | ||
|
|
dfc768c232 | ||
|
|
db46453912 | ||
|
|
6a16029839 | ||
|
|
782271a31b | ||
|
|
7aec61d1a5 | ||
|
|
025af147cd | ||
|
|
f150aea8b9 | ||
|
|
75cdfdebe4 | ||
|
|
11004626c7 | ||
|
|
8d28739ca6 | ||
|
|
e00e9ca2dc | ||
|
|
6b2819aa20 | ||
|
|
ffd434485e | ||
|
|
c90b6decc8 | ||
|
|
94c02d9ee6 | ||
|
|
0520041d1e | ||
|
|
5c1b35de7e | ||
|
|
5e46dc387d | ||
|
|
2438f4f7a8 | ||
|
|
fb039b725f | ||
|
|
c2de6cab10 | ||
|
|
1ffd46713a | ||
|
|
063ac9ec40 | ||
|
|
e2dab9d184 | ||
|
|
8fa59d9d19 | ||
|
|
5fe01532ca | ||
|
|
eb60f1a762 | ||
|
|
fb0687ec71 | ||
|
|
b0ad3a128e | ||
|
|
3608cb50cc | ||
|
|
6985b3e6ea | ||
|
|
5d0983fe41 | ||
|
|
077bc9afc0 | ||
|
|
57f85f5453 | ||
|
|
968ca15c27 | ||
|
|
3bb96eb6f3 | ||
|
|
fa40eebeed | ||
|
|
687e1f9fb0 | ||
|
|
77931db775 | ||
|
|
803e8f36c1 | ||
|
|
ed7db6e048 | ||
|
|
2ff08e5127 | ||
|
|
87bda8dbef | ||
|
|
964c8b5f5a | ||
|
|
d2120987b0 | ||
|
|
830d252346 | ||
|
|
160c05a511 | ||
|
|
9b47668fe9 | ||
|
|
c46dadeda4 | ||
|
|
c865e6f699 | ||
|
|
827595ef00 | ||
|
|
d189bb5af4 | ||
|
|
43dacea672 | ||
|
|
e4b6d5db21 | ||
|
|
bebd65c056 | ||
|
|
19e4e10b1d | ||
|
|
90107c83d5 | ||
|
|
13cb5f8670 | ||
|
|
804daeffca | ||
|
|
c9144dc293 | ||
|
|
7fc53396b4 | ||
|
|
c0975f8a43 | ||
|
|
43a624bbe7 | ||
|
|
c4532dd340 | ||
|
|
039b46d12b | ||
|
|
43ca6dc4b0 | ||
|
|
45b7a1d9ef | ||
|
|
a741e6103d | ||
|
|
6e27d9c4fe | ||
|
|
e5de0b53d4 | ||
|
|
cb91fbef6c | ||
|
|
9617e59747 | ||
|
|
d103316945 | ||
|
|
7ac5ed3155 | ||
|
|
a6b708080c | ||
|
|
d675a842e8 | ||
|
|
a1ba13c1b5 | ||
|
|
981f5a5998 | ||
|
|
e16cf9b28f |
No files matched your search
+11
-10
@@ -4,10 +4,13 @@
|
|||||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||||
|
|
||||||
# Optional pool tuning (defaults shown)
|
# Optional pool tuning (defaults shown)
|
||||||
DATABASE_POOL_SIZE=40
|
DATABASE_POOL_SIZE=10
|
||||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
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})
|
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||||
HOTEL_NAME=Atom
|
HOTEL_NAME=Atom
|
||||||
APP_URL=http://localhost:3000
|
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.
|
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
||||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
||||||
|
|
||||||
# Optional OAuth (enabled when both id+secret are set)
|
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||||
DISCORD_CLIENT_ID=
|
RESEND_API_KEY=
|
||||||
DISCORD_CLIENT_SECRET=
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Optional SMTP (password reset / alert emails)
|
# Optional SMTP fallback (password reset / alert emails)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
@@ -71,9 +71,10 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# Optional Redis — enables shared caching for rate limiting and site settings,
|
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||||
# allowing horizontal scaling across multiple instances. Falls back to in-process
|
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||||
# Maps when unset.
|
# 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
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
# 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"
|
|
||||||
@@ -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
@@ -24,17 +24,177 @@ jobs:
|
|||||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
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
|
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
|
fi
|
||||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
[ -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 }}}"
|
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||||
|
|
||||||
@@ -76,76 +236,182 @@ jobs:
|
|||||||
|
|
||||||
echo "--- Deploying ---"
|
echo "--- Deploying ---"
|
||||||
|
|
||||||
|
LIVE="/var/www/atom-nexst"
|
||||||
|
STAGE=""
|
||||||
|
CUTOVER_STARTED=0
|
||||||
|
|
||||||
error_handler() {
|
error_handler() {
|
||||||
|
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
trap 'error_handler $LINENO' ERR
|
trap 'error_handler $LINENO' ERR
|
||||||
|
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
cd /var/www/atom-nexst/
|
|
||||||
|
|
||||||
DEPLOY_USER="$(id -un)"
|
DEPLOY_USER="$(id -un)"
|
||||||
DEPLOY_GROUP="$(id -gn)"
|
DEPLOY_GROUP="$(id -gn)"
|
||||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" /var/www/atom-nexst/
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
git config --global --add safe.directory /var/www/atom-nexst
|
git config --global --add safe.directory "${LIVE}"
|
||||||
git remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||||
|
|
||||||
echo "Fetching origin/main..."
|
echo "Fetching origin/main..."
|
||||||
git fetch origin --prune
|
git -C "${LIVE}" fetch origin --prune
|
||||||
|
|
||||||
echo "Clearing sticky git index bits (if any)..."
|
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
|
if [ -n "${STICKY_LIST}" ]; then
|
||||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||||
[ -n "$f" ] || continue
|
[ -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
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Hard reset to origin/main..."
|
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short 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 NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||||
echo "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 install --frozen-lockfile
|
||||||
pnpm db:migrate
|
# prisma generate does not need a live DB connection.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
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
|
pnpm build
|
||||||
|
|
||||||
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
if [ ! -d "${STAGE}/.next" ]; then
|
||||||
|
echo "ERROR: stage build produced no .next/" >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ---"
|
echo "--- Deployed successfully ---"
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.turbo/
|
.turbo/
|
||||||
.next/
|
.next/
|
||||||
|
.next.prev/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -23,3 +24,6 @@ gitea
|
|||||||
|
|
||||||
# Runtime uploaded media (persistent, outside public/)
|
# Runtime uploaded media (persistent, outside public/)
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
# Test coverage reports
|
||||||
|
coverage/
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 110,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
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
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| 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) |
|
| **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 |
|
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
|
||||||
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
| **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) |
|
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
| **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 |
|
| **Compression** | Gzip compression enabled on all responses |
|
||||||
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
|
| **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
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
│ │ ├── motion.ts # Framer Motion animation variants
|
│ │ ├── motion.ts # Framer Motion animation variants
|
||||||
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
│ │ └── 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
|
│ └── env.ts # Zod-validated environment schema
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── assets/ # Images, icons, fonts
|
│ ├── assets/ # Images, icons, fonts
|
||||||
@@ -228,7 +250,7 @@ Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI
|
|||||||
```bash
|
```bash
|
||||||
pnpm dev # Start with hot reload
|
pnpm dev # Start with hot reload
|
||||||
pnpm typecheck # Type check all files
|
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 analyze # Build and analyze bundle sizes
|
||||||
pnpm biome:check # Lint and format
|
pnpm biome:check # Lint and format
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-5
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
|
"$schema": "https://biomejs.dev",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -22,9 +23,6 @@
|
|||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "off"
|
"noImgElement": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
|
||||||
"noLabelWithoutControl": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noDescendingSpecificity": "off"
|
"noDescendingSpecificity": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -1,65 +1,47 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import nextPlugin from "@next/eslint-plugin-next";
|
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 reactHooks from "eslint-plugin-react-hooks";
|
||||||
import security from "eslint-plugin-security";
|
import security from "eslint-plugin-security";
|
||||||
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
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: {
|
plugins: {
|
||||||
"@next/next": nextPlugin,
|
"unused-imports": unusedImports,
|
||||||
|
security: security,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"jsx-a11y": jsxA11y,
|
"@next/next": nextPlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
// Laad automatisch alle aanbevolen beveiligings- en framework-regels in
|
||||||
|
...security.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
...nextPlugin.configs.recommended.rules,
|
...nextPlugin.configs.recommended.rules,
|
||||||
|
|
||||||
"react-hooks/rules-of-hooks": "error",
|
// Zorg dat variabelen die beginnen met een underscore (_req) genegeerd worden
|
||||||
"react-hooks/exhaustive-deps": "warn",
|
|
||||||
|
|
||||||
"no-console": "off",
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"warn",
|
"error",
|
||||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
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-console": "warn",
|
||||||
"no-useless-assignment": "off",
|
|
||||||
"no-undef": "off",
|
|
||||||
|
|
||||||
"security/detect-object-injection": "warn",
|
|
||||||
"security/detect-non-literal-fs-filename": "warn",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
".next/",
|
|
||||||
"node_modules/",
|
|
||||||
"src/generated/",
|
|
||||||
"public/",
|
|
||||||
"prisma/migrations/",
|
|
||||||
"db_backup_*.sql",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
@@ -5,14 +5,15 @@
|
|||||||
"src/app/**/layout.{ts,tsx}",
|
"src/app/**/layout.{ts,tsx}",
|
||||||
"src/app/**/route.{ts,tsx}",
|
"src/app/**/route.{ts,tsx}",
|
||||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||||
"src/app/**/actions.{ts,tsx}",
|
"sentry.{server,edge}.config.ts"
|
||||||
"next.config.ts",
|
|
||||||
"src/middleware.ts"
|
|
||||||
],
|
],
|
||||||
"project": ["src/**/*.{ts,tsx}"],
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
"ignore": [
|
"ignoreDependencies": [
|
||||||
"src/**/*.test.{ts,tsx}",
|
"tailwindcss-animate",
|
||||||
"scripts/**"
|
"@tailwindcss/forms",
|
||||||
|
"@tailwindcss/typography",
|
||||||
|
"pino-pretty",
|
||||||
|
"tailwindcss"
|
||||||
],
|
],
|
||||||
"ignoreDependencies": ["@types/*", "knip"]
|
"ignoreBinaries": ["sendmail"]
|
||||||
}
|
}
|
||||||
@@ -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 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+23
-40
@@ -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 withBundleAnalyzer from "@next/bundle-analyzer";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
@@ -16,25 +16,11 @@ const securityHeaders = [
|
|||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||||
},
|
},
|
||||||
{
|
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
|
||||||
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("; "),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: { root: import.meta.dirname },
|
turbopack: {},
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
"@prisma/adapter-mariadb",
|
"@prisma/adapter-mariadb",
|
||||||
"mariadb",
|
"mariadb",
|
||||||
@@ -51,14 +37,8 @@ const nextConfig: NextConfig = {
|
|||||||
// Compress responses with gzip
|
// Compress responses with gzip
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
||||||
// Cache pages longer in the router cache for faster back/forward navigation
|
// Disable Next.js telemetry
|
||||||
experimental: {
|
productionBrowserSourceMaps: false,
|
||||||
staleTimes: {
|
|
||||||
dynamic: 30,
|
|
||||||
static: 180,
|
|
||||||
},
|
|
||||||
optimizePackageImports: ["lucide-react"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
@@ -99,16 +79,19 @@ const withBA = withBundleAnalyzer({
|
|||||||
enabled: process.env.ANALYZE === "true",
|
enabled: process.env.ANALYZE === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBA(withSentryConfig(config, {
|
export default withBA(
|
||||||
org: process.env.SENTRY_ORG,
|
withSentryConfig(config, {
|
||||||
project: process.env.SENTRY_PROJECT,
|
org: process.env.SENTRY_ORG,
|
||||||
authToken: sentryAuthToken,
|
project: process.env.SENTRY_PROJECT,
|
||||||
silent: !process.env.CI,
|
authToken: sentryAuthToken,
|
||||||
widenClientFileUpload: true,
|
silent: !process.env.CI || !sentryAuthToken,
|
||||||
sourcemaps: {
|
widenClientFileUpload: true,
|
||||||
disable: !sentryAuthToken,
|
sourcemaps: {
|
||||||
},
|
disable: !sentryAuthToken,
|
||||||
release: {
|
},
|
||||||
create: Boolean(sentryAuthToken),
|
release: {
|
||||||
},
|
create: Boolean(sentryAuthToken),
|
||||||
}));
|
},
|
||||||
|
telemetry: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
+62
-32
@@ -11,27 +11,44 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"typecheck": "tsc --noEmit --incremental false",
|
"typecheck": "tsc6 --noEmit --incremental false",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
|
"knip": "knip",
|
||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"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": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.8.0",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.9.1",
|
||||||
"@sentry/nextjs": "^10.66.0",
|
"@sentry/nextjs": "^10.68.0",
|
||||||
"@tanstack/react-virtual": "^3.14.6",
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -43,49 +60,62 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lenis": "^1.3.25",
|
"lenis": "^1.3.25",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.27.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.14.0",
|
||||||
"mysql2": "^3.23.0",
|
"mysql2": "^3.23.2",
|
||||||
"next": "^16.2.10",
|
"next": "^16.2.12",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.2",
|
"next-intl": "^4.13.4",
|
||||||
"next-view-transitions": "^0.3.5",
|
"next-view-transitions": "^0.3.5",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^13.4.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.81.0",
|
"react-hook-form": "^7.83.0",
|
||||||
"resend": "^6.17.2",
|
"resend": "^6.18.1",
|
||||||
"sanitize-html": "^2.17.6",
|
"sanitize-html": "^2.17.6",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.24.0"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.4",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@next/bundle-analyzer": "^16.2.10",
|
"@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/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/node": "^22.10.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.0",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^17.4.2",
|
||||||
"knip": "^6.27.0",
|
"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",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.24",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
"vitest": "^2.1.0"
|
"typescript-eslint": "^8.65.0",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+5249
-1977
File diff suppressed because it is too large.
Load diff
+30
-9
@@ -1,14 +1,35 @@
|
|||||||
onlyBuiltDependencies:
|
# pnpm-workspace.yaml
|
||||||
- esbuild
|
|
||||||
- prisma
|
|
||||||
- "@prisma/client"
|
|
||||||
- "@prisma/engines"
|
|
||||||
- sharp
|
|
||||||
- "@parcel/watcher"
|
|
||||||
- "@swc/core"
|
|
||||||
- "@sentry/cli"
|
|
||||||
|
|
||||||
|
# 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:
|
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"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13"
|
||||||
postcss: "^8.5.19"
|
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
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
@@ -1,5 +1,5 @@
|
|||||||
const CACHE = "atom-v2";
|
const CACHE = "atom-v3";
|
||||||
const API_CACHE = "atom-api-v2";
|
const API_CACHE = "atom-api-v3";
|
||||||
|
|
||||||
self.addEventListener("install", () => self.skipWaiting());
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => {
|
|||||||
.keys()
|
.keys()
|
||||||
.then((keys) =>
|
.then((keys) =>
|
||||||
Promise.all(
|
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()),
|
.then(() => self.clients.claim()),
|
||||||
@@ -60,14 +62,6 @@ self.addEventListener("fetch", (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.mode === "navigate") {
|
if (req.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(fetch(req));
|
||||||
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("/"))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+66
-66
@@ -24,27 +24,28 @@ function getDbConfig(): { url: string; database: string } {
|
|||||||
return { url: mysqlConnectionUrl(url), database: dbName };
|
return { url: mysqlConnectionUrl(url), database: dbName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnection(): Promise<void> {
|
async function openConnection() {
|
||||||
const { url } = getDbConfig();
|
const { url } = getDbConfig();
|
||||||
const mysql = await import("mysql2/promise");
|
const mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(url);
|
return mysql.createConnection(url);
|
||||||
try {
|
}
|
||||||
await conn.execute(
|
void openConnection;
|
||||||
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
|
||||||
|
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,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
migration VARCHAR(255) NOT NULL UNIQUE,
|
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getApplied(): Promise<Set<string>> {
|
async function getApplied(
|
||||||
const { url } = getDbConfig();
|
conn: import("mysql2/promise").Connection,
|
||||||
const mysql = await import("mysql2/promise");
|
): Promise<Set<string>> {
|
||||||
const conn = await mysql.createConnection(url);
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await conn.execute(
|
const [rows] = await conn.execute(
|
||||||
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
`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));
|
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,69 +69,70 @@ function loadMigrations(): MigrationFile[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apply(migration: MigrationFile): Promise<void> {
|
async function apply(
|
||||||
const { url } = getDbConfig();
|
conn: import("mysql2/promise").Connection,
|
||||||
const mysql = await import("mysql2/promise");
|
migration: MigrationFile,
|
||||||
const conn = await mysql.createConnection(url);
|
): Promise<void> {
|
||||||
try {
|
const statements = splitSqlStatements(migration.sql);
|
||||||
const statements = splitSqlStatements(migration.sql);
|
|
||||||
|
|
||||||
for (const stmt of statements) {
|
for (const stmt of statements) {
|
||||||
await conn.execute(stmt);
|
await conn.execute(stmt);
|
||||||
}
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
|
|
||||||
[migration.id],
|
|
||||||
);
|
|
||||||
console.log(`[migrate] Applied: ${migration.name}`);
|
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO \`${TRACKING_TABLE}\` (migration) VALUES (?)`,
|
||||||
|
[migration.id],
|
||||||
|
);
|
||||||
|
console.log(`[migrate] Applied: ${migration.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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") {
|
try {
|
||||||
await ensureConnection();
|
await createTrackingTable(conn);
|
||||||
const applied = await getApplied();
|
const applied = await getApplied(conn);
|
||||||
const all = loadMigrations();
|
|
||||||
|
|
||||||
console.log("\nMigration status:\n");
|
if (process.argv[2] === "--status") {
|
||||||
for (const m of all) {
|
const all = loadMigrations();
|
||||||
const done = applied.has(m.id);
|
|
||||||
console.log(
|
console.log("\nMigration status:\n");
|
||||||
` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`,
|
for (const m of all) {
|
||||||
);
|
const done = applied.has(m.id);
|
||||||
|
console.log(
|
||||||
|
` ${done ? "✓" : " "} ${m.name}${done ? "" : " [PENDING]"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = all.filter((m) => !applied.has(m.id));
|
||||||
|
const total = all.length;
|
||||||
|
const done = total - pending.length;
|
||||||
|
console.log(`\n${done}/${total} applied, ${pending.length} pending\n`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = all.filter((m) => !applied.has(m.id));
|
const pending = loadMigrations().filter((m) => !applied.has(m.id));
|
||||||
const total = all.length;
|
|
||||||
const done = total - pending.length;
|
|
||||||
console.log(`\n${done}/${total} applied, ${pending.length} pending\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureConnection();
|
if (pending.length === 0) {
|
||||||
const applied = await getApplied();
|
console.log("[migrate] All migrations already applied.");
|
||||||
const pending = loadMigrations().filter((m) => !applied.has(m.id));
|
return;
|
||||||
|
|
||||||
if (pending.length === 0) {
|
|
||||||
console.log("[migrate] All migrations already applied.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
|
|
||||||
for (const m of pending) {
|
|
||||||
try {
|
|
||||||
await apply(m);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[migrate] FAILED: ${m.name}`, err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
|
||||||
|
for (const m of pending) {
|
||||||
|
try {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
console.log("\n[migrate] Done.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
Executable
+17
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
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";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
// 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",
|
targetType: "website_ad",
|
||||||
targetId: Number(ad.id),
|
targetId: Number(ad.id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// DB error — page re-renders unchanged.
|
logger.error("Action failed: createAd", {
|
||||||
|
action: "createAd",
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/admin/ads");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,15 +69,47 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(id),
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not found or DB error — ignore.
|
logger.error("Action failed: updateAd", {
|
||||||
|
action: "updateAd",
|
||||||
|
id: Number(id),
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
revalidatePath(`/admin/ads/${id}`);
|
revalidatePath(`/admin/ads/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
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 staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -84,8 +123,12 @@ export async function deleteAd(formData: FormData): Promise<void> {
|
|||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(id),
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not found or DB error — ignore.
|
logger.error("Action failed: deleteAdForm", {
|
||||||
|
action: "deleteAdForm",
|
||||||
|
id: Number(id),
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
redirect("/admin/ads");
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
@@ -74,9 +74,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
...(rawSlug
|
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||||
? { slug: await uniqueSlug(rawSlug) }
|
|
||||||
: {}),
|
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
shortStory: String(formData.get("shortStory") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const BAN_TYPES: ReadonlySet<string> = new Set([
|
|||||||
"machine",
|
"machine",
|
||||||
"super",
|
"super",
|
||||||
]);
|
]);
|
||||||
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
|
|
||||||
|
|
||||||
export async function createBan(formData: FormData): Promise<void> {
|
export async function createBan(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const userId = Number(formData.get("userId"));
|
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;
|
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const banExpire =
|
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||||
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
|
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
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 imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
const buttonColor =
|
||||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
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 imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
const buttonColor =
|
||||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.update({
|
await prisma.websiteHelpCenterCategories.update({
|
||||||
|
|||||||
@@ -1,194 +1,34 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { auth } from "@/lib/auth";
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
import { sessionUserId } from "@/lib/auth/session-user";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
|
|
||||||
function sanitize(str: string, max = 255): string {
|
/**
|
||||||
return String(str ?? "")
|
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
||||||
.normalize("NFC")
|
* Redirect editors to live Permissions.
|
||||||
.trim()
|
*/
|
||||||
.slice(0, max);
|
async function rejectLegacyHousekeepingWrite(): Promise<never> {
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
return redirectSafe(
|
||||||
const permission = sanitize(String(formData.get("permission") ?? ""));
|
"/admin/permissions?from=housekeeping",
|
||||||
const minRank = Number(formData.get("minRank"));
|
"/admin/permissions",
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePermission(formData: FormData): Promise<void> {
|
export async function upsertPermission(_formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
}
|
||||||
|
|
||||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
export async function deletePermission(_formData: FormData): Promise<void> {
|
||||||
if (!raw) return;
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
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 bulkImportPermissions(
|
export async function bulkImportPermissions(
|
||||||
formData: FormData,
|
_formData: FormData,
|
||||||
): Promise<{ count: number; errors: string[] }> {
|
): Promise<{ count: number; errors: string[] }> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPermissions(): Promise<string> {
|
export async function exportPermissions(): Promise<string> {
|
||||||
@@ -208,688 +48,16 @@ export async function exportPermissions(): Promise<string> {
|
|||||||
return JSON.stringify(perms, null, 2);
|
return JSON.stringify(perms, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPreset(formData: FormData): Promise<void> {
|
export async function applyPreset(_formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
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 clearAllPermissions(): Promise<void> {
|
export async function clearAllPermissions(): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeletePermissions(
|
export async function bulkDeletePermissions(
|
||||||
ids: bigint[],
|
_ids: bigint[],
|
||||||
): Promise<{ count: number }> {
|
): Promise<{ count: number }> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
return rejectLegacyHousekeepingWrite();
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
@@ -19,7 +19,10 @@ export async function uploadMedia(
|
|||||||
if (file.size > MAX_SIZE)
|
if (file.size > MAX_SIZE)
|
||||||
return { ok: false, error: "File too large (max 5MB)" };
|
return { ok: false, error: "File too large (max 5MB)" };
|
||||||
if (!ALLOWED.includes(file.type))
|
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;
|
const baseDir = MEDIA_ROOT;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function createCategory(formData: FormData): Promise<void> {
|
export async function createCategory(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from "@/lib/habbo-gamedata-hotel";
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
|
||||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||||
|
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||||
import {
|
import {
|
||||||
deleteCustomThemeStore,
|
deleteCustomThemeStore,
|
||||||
getCustomTheme,
|
getCustomTheme,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from "@/lib/theme-custom-store";
|
} from "@/lib/theme-custom-store";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
|
||||||
|
|
||||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
@@ -46,7 +46,10 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
const fixed = ensureReadableThemeColors(bag);
|
const fixed = ensureReadableThemeColors(bag);
|
||||||
for (const [key, value] of Object.entries(fixed)) {
|
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 = [
|
const ADMIN_KEYS = [
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
actionOk,
|
actionOk,
|
||||||
type ActionResult,
|
|
||||||
} from "@/lib/safe-action-shared";
|
} from "@/lib/safe-action-shared";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
actionOk,
|
actionOk,
|
||||||
type ActionResult,
|
|
||||||
} from "@/lib/safe-action-shared";
|
} from "@/lib/safe-action-shared";
|
||||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -33,9 +33,7 @@ export async function addWord(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWord(input: {
|
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||||
id: string;
|
|
||||||
}): Promise<ActionResult> {
|
|
||||||
await requirePermission(PERMS.WORDFILTER_EDIT);
|
await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||||
const raw = String(input.id ?? "").normalize("NFC");
|
const raw = String(input.id ?? "").normalize("NFC");
|
||||||
if (!raw) return actionError("Missing word id");
|
if (!raw) return actionError("Missing word id");
|
||||||
|
|||||||
+112
-64
@@ -1,98 +1,146 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
||||||
// cap the write defensively (column is TEXT, but we keep applications sane).
|
// cap the write defensively (column is TEXT, but we keep applications sane).
|
||||||
const CONTENT_MIN = 10;
|
const CONTENT_MIN = 10;
|
||||||
const CONTENT_MAX = 5000;
|
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.
|
* 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> {
|
export async function applyStaff(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: ApplyOutcome = "error";
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
|
||||||
const rankId = Number(formData.get("rankId"));
|
|
||||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
|
||||||
|
|
||||||
const content = String(formData.get("content") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, CONTENT_MAX);
|
|
||||||
if (content.length < CONTENT_MIN) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
const session = await auth();
|
||||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
const userId = Number(session?.user?.id);
|
||||||
where: { userId, rankId },
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
select: { id: true },
|
redirect("/login");
|
||||||
});
|
}
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
await clientIp();
|
||||||
await prisma.websiteStaffApplications.create({
|
if (!(await rateLimit(`apply-staff:${userId}`, 3, 60_000)).ok) {
|
||||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
outcome = "ratelimit";
|
||||||
});
|
} else {
|
||||||
} catch {
|
const rankId = Number(formData.get("rankId"));
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
if (!Number.isInteger(rankId) || rankId <= 0) {
|
||||||
return;
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const content = String(formData.get("content") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, CONTENT_MAX);
|
||||||
|
if (content.length < CONTENT_MIN) {
|
||||||
|
outcome = "empty";
|
||||||
|
} else {
|
||||||
|
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||||
|
where: { userId, rankId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
outcome = "duplicate";
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.websiteStaffApplications.create({
|
||||||
|
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||||
|
});
|
||||||
|
outcome = "submitted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/apply/staff");
|
revalidatePath("/apply/staff");
|
||||||
|
staffRedirect(outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a TEAM application.
|
* 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> {
|
export async function applyTeam(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: ApplyOutcome = "error";
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
|
||||||
// id is the application's rank flag.
|
|
||||||
const rankId = Number(formData.get("teamId"));
|
|
||||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
|
||||||
|
|
||||||
const content = String(formData.get("content") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, CONTENT_MAX);
|
|
||||||
if (content.length < CONTENT_MIN) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
const session = await auth();
|
||||||
where: { userId, rankId },
|
const userId = Number(session?.user?.id);
|
||||||
select: { id: true },
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
});
|
redirect("/login");
|
||||||
if (existing) return;
|
}
|
||||||
|
|
||||||
const now = new Date();
|
await clientIp();
|
||||||
await prisma.websiteStaffApplications.create({
|
if (!(await rateLimit(`apply-team:${userId}`, 3, 60_000)).ok) {
|
||||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
outcome = "ratelimit";
|
||||||
});
|
} else {
|
||||||
} catch {
|
const rankId = Number(formData.get("teamId"));
|
||||||
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) {
|
||||||
|
outcome = "empty";
|
||||||
|
} else {
|
||||||
|
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||||
|
where: { userId, rankId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
outcome = "duplicate";
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.websiteStaffApplications.create({
|
||||||
|
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||||
|
});
|
||||||
|
outcome = "submitted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/apply/team");
|
revalidatePath("/apply/team");
|
||||||
|
teamRedirect(outcome);
|
||||||
}
|
}
|
||||||
@@ -1,71 +1,116 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { isAllowed } from "@/lib/services/moderation";
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
||||||
const COMMENT_MAX = 255;
|
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
|
* 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,
|
* 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
|
* 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).
|
* 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> {
|
export async function postComment(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
const slugHint = String(formData.get("slug") ?? "")
|
||||||
if (!session?.user?.id) return;
|
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
|
||||||
if (!Number.isFinite(userId)) return;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
|
||||||
|
|
||||||
let articleId: bigint;
|
let outcome: CommentOutcome = "error";
|
||||||
|
let slug = slugHint;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
articleId = BigInt(articleIdRaw);
|
const session = await auth();
|
||||||
} catch {
|
if (!session?.user?.id) {
|
||||||
return;
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug: string | null;
|
const userId = Number(session.user.id);
|
||||||
try {
|
if (!Number.isFinite(userId) || userId <= 0) {
|
||||||
// Confirm the article exists (and grab its slug for revalidation).
|
redirect("/login");
|
||||||
const article = await prisma.websiteArticles.findUnique({
|
}
|
||||||
where: { id: articleId },
|
|
||||||
select: { slug: true },
|
|
||||||
});
|
|
||||||
if (!article) return;
|
|
||||||
slug = article.slug;
|
|
||||||
|
|
||||||
const now = new Date();
|
await clientIp();
|
||||||
await prisma.websiteArticleComments.create({
|
if (!(await rateLimit(`comment:${userId}`, 5, 30_000)).ok) {
|
||||||
data: {
|
outcome = "ratelimit";
|
||||||
articleId,
|
} else {
|
||||||
userId,
|
const comment = String(formData.get("comment") ?? "")
|
||||||
comment,
|
.normalize("NFC")
|
||||||
createdAt: now,
|
.trim()
|
||||||
updatedAt: now,
|
.slice(0, COMMENT_MAX);
|
||||||
},
|
if (!comment) {
|
||||||
});
|
outcome = "empty";
|
||||||
} catch {
|
} else if (!(await isAllowed(comment)).ok) {
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
outcome = "moderated";
|
||||||
return;
|
} else {
|
||||||
|
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
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) {
|
||||||
|
outcome = "not_found";
|
||||||
|
} else {
|
||||||
|
slug = article.slug;
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.websiteArticleComments.create({
|
||||||
|
data: {
|
||||||
|
articleId,
|
||||||
|
userId,
|
||||||
|
comment,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "posted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug) revalidatePath(`/news/${slug}`);
|
if (slug) revalidatePath(`/news/${slug}`);
|
||||||
|
commentRedirect(slug, outcome);
|
||||||
}
|
}
|
||||||
@@ -1,99 +1,126 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
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
|
// 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
|
// 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.
|
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
|
||||||
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
|
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.
|
* 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> {
|
export async function toggleReaction(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
const slugHint = String(formData.get("slug") ?? "")
|
||||||
if (!session?.user?.id) return;
|
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
|
||||||
if (!Number.isFinite(userId)) return;
|
|
||||||
|
|
||||||
const reaction = String(formData.get("reaction") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
|
||||||
|
|
||||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
|
||||||
|
|
||||||
let articleId: bigint;
|
let outcome: ReactionOutcome = "error";
|
||||||
|
let slug = slugHint;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
articleId = BigInt(articleIdRaw);
|
const session = await auth();
|
||||||
} catch {
|
if (!session?.user?.id) {
|
||||||
return;
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug: string | null;
|
const userId = Number(session.user.id);
|
||||||
try {
|
if (!Number.isFinite(userId)) {
|
||||||
// Confirm the article exists (and grab its slug for revalidation).
|
redirect("/login");
|
||||||
const article = await prisma.websiteArticles.findUnique({
|
}
|
||||||
where: { id: articleId },
|
|
||||||
select: { slug: true },
|
|
||||||
});
|
|
||||||
if (!article) return;
|
|
||||||
slug = article.slug;
|
|
||||||
|
|
||||||
// The user's current row for THIS reaction on THIS article, if any.
|
await clientIp();
|
||||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) {
|
||||||
where: { userId, articleId, reaction },
|
outcome = "ratelimit";
|
||||||
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 {
|
} else {
|
||||||
// Switching to (or first-time picking) this reaction: clear any other
|
const reaction = String(formData.get("reaction") ?? "")
|
||||||
// active reaction by this user on this article, then activate this one.
|
.normalize("NFC")
|
||||||
await prisma.websiteArticleReactions.updateMany({
|
.trim()
|
||||||
where: { userId, articleId, active: true },
|
.toLowerCase();
|
||||||
data: { active: false },
|
if (!ALLOWED_REACTIONS.has(reaction)) {
|
||||||
});
|
outcome = "invalid";
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await prisma.websiteArticleReactions.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: { active: true },
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await prisma.websiteArticleReactions.create({
|
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||||
data: { userId, articleId, reaction, active: true },
|
.normalize("NFC")
|
||||||
});
|
.trim();
|
||||||
|
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) {
|
||||||
|
outcome = "not_found";
|
||||||
|
} else {
|
||||||
|
slug = article.slug;
|
||||||
|
|
||||||
|
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||||
|
where: { userId, articleId, reaction },
|
||||||
|
select: { id: true, active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing?.active) {
|
||||||
|
await prisma.websiteArticleReactions.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.websiteArticleReactions.updateMany({
|
||||||
|
where: { userId, articleId, active: true },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.websiteArticleReactions.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { active: true },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.websiteArticleReactions.create({
|
||||||
|
data: { userId, articleId, reaction, active: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome = "updated";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
if (isNextRedirect(e)) throw e;
|
||||||
return;
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug) revalidatePath(`/news/${slug}`);
|
if (slug && outcome !== "error") revalidatePath(`/news/${slug}`);
|
||||||
|
reactionRedirect(slug, outcome);
|
||||||
}
|
}
|
||||||
@@ -4,16 +4,25 @@ import { env } from "@/env";
|
|||||||
import { checkLogin } from "@/lib/auth/password";
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
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
|
* 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.
|
* 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(
|
export async function precheckLogin(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
captchaToken?: string | null,
|
||||||
): Promise<PrecheckResult> {
|
): Promise<PrecheckResult> {
|
||||||
const u = String(username ?? "")
|
const u = String(username ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -21,14 +30,29 @@ export async function precheckLogin(
|
|||||||
const p = String(password ?? "");
|
const p = String(password ?? "");
|
||||||
if (!u || !p) return "invalid";
|
if (!u || !p) return "invalid";
|
||||||
|
|
||||||
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
|
const ip = await clientIp();
|
||||||
return "invalid";
|
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 {
|
try {
|
||||||
user = await prisma.user.findUnique({
|
user = await prisma.user.findUnique({
|
||||||
where: { username: u },
|
where: { username: u },
|
||||||
select: { password: true, twoFactorConfirmedAt: true },
|
select: {
|
||||||
|
password: true,
|
||||||
|
twoFactorConfirmedAt: true,
|
||||||
|
mail: true,
|
||||||
|
mailVerified: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return "invalid";
|
return "invalid";
|
||||||
@@ -50,5 +74,13 @@ export async function precheckLogin(
|
|||||||
});
|
});
|
||||||
if (!res.valid) return "invalid";
|
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";
|
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
|
||||||
}
|
}
|
||||||
+228
-191
@@ -1,230 +1,267 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permissions";
|
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";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
const PATH = "/admin/commandocentrum";
|
const PATH = "/admin/commandocentrum";
|
||||||
|
|
||||||
async function requireRcon(): Promise<void> {
|
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||||
await requirePermission(PERMS.RCON_EXECUTE);
|
|
||||||
|
async function requireRconOk(ok: boolean): Promise<void> {
|
||||||
|
if (!ok) throw new ActionError(RCON_FAIL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||||
export async function updateCatalog(): Promise<void> {
|
export const updateCatalog = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
try {
|
async () => {
|
||||||
await rcon.updateCatalog();
|
await requireRconOk(await rcon.updateCatalog());
|
||||||
} catch {
|
revalidatePath(PATH);
|
||||||
// RCON is best-effort; a dead socket must not 500 the admin page.
|
return actionOk();
|
||||||
}
|
},
|
||||||
revalidatePath(PATH);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||||
export async function updateWordFilter(): Promise<void> {
|
export const updateWordFilter = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
try {
|
async () => {
|
||||||
await rcon.updateWordFilter();
|
await requireRconOk(await rcon.updateWordFilter());
|
||||||
} catch {
|
revalidatePath(PATH);
|
||||||
// best-effort
|
return actionOk();
|
||||||
}
|
},
|
||||||
revalidatePath(PATH);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||||
export async function updateNavigator(): Promise<void> {
|
export const updateNavigator = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE },
|
||||||
try {
|
async () => {
|
||||||
await rcon.send("updatenavigator", null);
|
await requireRconOk(await rcon.send("updatenavigator", null));
|
||||||
} catch {
|
revalidatePath(PATH);
|
||||||
// best-effort
|
return actionOk();
|
||||||
}
|
},
|
||||||
revalidatePath(PATH);
|
);
|
||||||
}
|
|
||||||
|
const hotelAlertSchema = z.object({
|
||||||
|
message: z.string().trim().min(1).max(512),
|
||||||
|
});
|
||||||
|
|
||||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
export const hotelAlert = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||||
const message = String(formData.get("message") ?? "")
|
async (ctx) => {
|
||||||
.normalize("NFC")
|
const message = ctx.data.message.normalize("NFC");
|
||||||
.trim()
|
await requireRconOk(await rcon.send("hotelalert", { message }));
|
||||||
.slice(0, 512);
|
revalidatePath(PATH);
|
||||||
if (!message) return;
|
return actionOk();
|
||||||
try {
|
},
|
||||||
await rcon.send("hotelalert", { message });
|
);
|
||||||
} catch {
|
|
||||||
// best-effort
|
const disconnectSchema = z.object({
|
||||||
}
|
userId: z.coerce.number().int().positive(),
|
||||||
revalidatePath(PATH);
|
username: z.string().trim().min(1),
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
export const disconnectUser = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const username = String(formData.get("username") ?? "")
|
const username = ctx.data.username.normalize("NFC");
|
||||||
.normalize("NFC")
|
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
|
||||||
.trim();
|
revalidatePath(PATH);
|
||||||
if (!userId || !username) return;
|
return actionOk();
|
||||||
try {
|
},
|
||||||
await rcon.disconnectUser(userId, username);
|
);
|
||||||
} catch {
|
|
||||||
// best-effort
|
const alertUserSchema = z.object({
|
||||||
}
|
userId: z.coerce.number().int().positive(),
|
||||||
revalidatePath(PATH);
|
message: z.string().trim().min(1).max(512),
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Send an alert to a specific user (rcon: alertuser). */
|
/** Send an alert to a specific user (rcon: alertuser). */
|
||||||
export async function alertUser(formData: FormData): Promise<void> {
|
export const alertUser = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const message = String(formData.get("message") ?? "")
|
const message = ctx.data.message.normalize("NFC");
|
||||||
.normalize("NFC")
|
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
|
||||||
.trim()
|
revalidatePath(PATH);
|
||||||
.slice(0, 512);
|
return actionOk();
|
||||||
if (!userId || !message) return;
|
},
|
||||||
try {
|
);
|
||||||
await rcon.alertUser(userId, message);
|
|
||||||
} catch {
|
const forwardUserSchema = z.object({
|
||||||
// best-effort
|
userId: z.coerce.number().int().positive(),
|
||||||
}
|
roomId: z.coerce.number().int().positive(),
|
||||||
revalidatePath(PATH);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||||
export async function forwardUser(formData: FormData): Promise<void> {
|
export const forwardUser = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const roomId = Number(formData.get("roomId"));
|
await requireRconOk(
|
||||||
if (!userId || !roomId) return;
|
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
|
||||||
try {
|
);
|
||||||
await rcon.forwardUser(userId, roomId);
|
revalidatePath(PATH);
|
||||||
} catch {
|
return actionOk();
|
||||||
// best-effort
|
},
|
||||||
}
|
);
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
const giveCreditsSchema = z.object({
|
||||||
|
userId: z.coerce.number().int().positive(),
|
||||||
|
credits: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
/** Give credits to a user (rcon: givecredits). */
|
/** Give credits to a user (rcon: givecredits). */
|
||||||
export async function giveCredits(formData: FormData): Promise<void> {
|
export const giveCredits = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const credits = Number(formData.get("credits"));
|
await requireRconOk(
|
||||||
if (!userId || !credits || credits <= 0) return;
|
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
|
||||||
try {
|
);
|
||||||
await rcon.giveCredits(userId, credits);
|
revalidatePath(PATH);
|
||||||
} catch {
|
return actionOk();
|
||||||
// best-effort
|
},
|
||||||
}
|
);
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
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). */
|
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||||
export async function giveDuckets(formData: FormData): Promise<void> {
|
export const giveDuckets = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const amount = Number(formData.get("amount"));
|
await requireRconOk(
|
||||||
if (!userId || !amount || amount <= 0) return;
|
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
|
||||||
try {
|
);
|
||||||
await rcon.giveDuckets(userId, amount);
|
revalidatePath(PATH);
|
||||||
} catch {
|
return actionOk();
|
||||||
// best-effort
|
},
|
||||||
}
|
);
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||||
export async function giveDiamonds(formData: FormData): Promise<void> {
|
export const giveDiamonds = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const amount = Number(formData.get("amount"));
|
await requireRconOk(
|
||||||
if (!userId || !amount || amount <= 0) return;
|
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
|
||||||
try {
|
);
|
||||||
await rcon.giveDiamonds(userId, amount);
|
revalidatePath(PATH);
|
||||||
} catch {
|
return actionOk();
|
||||||
// best-effort
|
},
|
||||||
}
|
);
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
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). */
|
/** Give a badge to a user (rcon: givebadge). */
|
||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
export const giveBadge = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const badge = String(formData.get("badge") ?? "")
|
const badge = ctx.data.badge.normalize("NFC");
|
||||||
.normalize("NFC")
|
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
|
||||||
.trim();
|
revalidatePath(PATH);
|
||||||
if (!userId || !badge) return;
|
return actionOk();
|
||||||
try {
|
},
|
||||||
await rcon.giveBadge(userId, badge);
|
);
|
||||||
} catch {
|
|
||||||
// best-effort
|
const setMottoSchema = z.object({
|
||||||
}
|
userId: z.coerce.number().int().positive(),
|
||||||
revalidatePath(PATH);
|
motto: z.string().trim().min(1).max(127),
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Set a user's motto (rcon: setmotto). */
|
/** Set a user's motto (rcon: setmotto). */
|
||||||
export async function setMotto(formData: FormData): Promise<void> {
|
export const setMotto = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const motto = String(formData.get("motto") ?? "")
|
const motto = ctx.data.motto.normalize("NFC");
|
||||||
.normalize("NFC")
|
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
|
||||||
.trim()
|
revalidatePath(PATH);
|
||||||
.slice(0, 127);
|
return actionOk();
|
||||||
if (!userId || !motto) return;
|
},
|
||||||
try {
|
);
|
||||||
await rcon.setMotto(userId, motto);
|
|
||||||
} catch {
|
const setRankSchema = z.object({
|
||||||
// best-effort
|
userId: z.coerce.number().int().positive(),
|
||||||
}
|
rank: z.coerce.number().int().min(1).max(9999),
|
||||||
revalidatePath(PATH);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/** Set a user's rank (rcon: setrank). */
|
/** Set a user's rank (rcon: setrank). */
|
||||||
export async function setRank(formData: FormData): Promise<void> {
|
export const setRank = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const rank = Number(formData.get("rank"));
|
const staffRank = Number(ctx.session.user.rank);
|
||||||
if (!userId || rank < 0 || rank > 10) return;
|
const isSuper = ctx.permissions.isSuperAdmin;
|
||||||
try {
|
const target = await prisma.user.findUnique({
|
||||||
await rcon.setRank(userId, rank);
|
where: { id: ctx.data.userId },
|
||||||
} catch {
|
select: { rank: true },
|
||||||
// best-effort
|
});
|
||||||
}
|
if (!target) throw new ActionError("User not found");
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
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). */
|
/** Execute a command as a user (rcon: executecommand). */
|
||||||
export async function executeCommand(formData: FormData): Promise<void> {
|
export const executeCommand = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const command = String(formData.get("command") ?? "")
|
const command = ctx.data.command.normalize("NFC");
|
||||||
.normalize("NFC")
|
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
|
||||||
.trim();
|
revalidatePath(PATH);
|
||||||
if (!userId || !command) return;
|
return actionOk();
|
||||||
try {
|
},
|
||||||
await rcon.executeCommand(userId, command);
|
);
|
||||||
} catch {
|
|
||||||
// best-effort
|
const sendGiftSchema = z.object({
|
||||||
}
|
userId: z.coerce.number().int().positive(),
|
||||||
revalidatePath(PATH);
|
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). */
|
/** Send a gift to a user (rcon: sendgift). */
|
||||||
export async function sendGift(formData: FormData): Promise<void> {
|
export const sendGift = adminAction(
|
||||||
await requireRcon();
|
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||||
const userId = Number(formData.get("userId"));
|
async (ctx) => {
|
||||||
const itemId = Number(formData.get("itemId"));
|
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
|
||||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
await requireRconOk(
|
||||||
.trim()
|
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
|
||||||
.slice(0, 255);
|
);
|
||||||
if (!userId || !itemId) return;
|
revalidatePath(PATH);
|
||||||
try {
|
return actionOk();
|
||||||
await rcon.sendGift(userId, itemId, message);
|
},
|
||||||
} catch {
|
);
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
revalidatePath(PATH);
|
|
||||||
}
|
|
||||||
+56
-48
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -55,63 +56,70 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
.trim();
|
.trim();
|
||||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||||
|
|
||||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
|
||||||
let boughtCode = "";
|
let boughtCode = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
await clientIp();
|
||||||
where: { id: BigInt(rawId) },
|
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||||
select: { id: true, badgePath: true, published: true },
|
outcome = "ratelimit";
|
||||||
});
|
|
||||||
|
|
||||||
if (!badge?.published) {
|
|
||||||
outcome = "invalid";
|
|
||||||
} else {
|
} else {
|
||||||
const code = badgeCodeFromPath(badge.badgePath);
|
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||||
if (code.length === 0) {
|
where: { id: BigInt(rawId) },
|
||||||
|
select: { id: true, badgePath: true, published: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!badge?.published) {
|
||||||
outcome = "invalid";
|
outcome = "invalid";
|
||||||
} else {
|
} else {
|
||||||
const price = await resolvePrice();
|
const code = badgeCodeFromPath(badge.badgePath);
|
||||||
|
if (code.length === 0) {
|
||||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
outcome = "invalid";
|
||||||
const buyer = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { credits: true },
|
|
||||||
});
|
|
||||||
if (!buyer || buyer.credits < price) {
|
|
||||||
outcome = "credits";
|
|
||||||
} else {
|
} else {
|
||||||
// Atomically deduct credits and persist the badge so a failure
|
const price = await resolvePrice();
|
||||||
// between the two operations cannot orphan the user.
|
|
||||||
if (price > 0) {
|
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
|
||||||
await tx.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { credits: { decrement: price } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = await tx.usersBadges.findFirst({
|
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||||
where: { userId, badgeCode: code },
|
const buyer = await prisma.user.findUnique({
|
||||||
select: { id: true },
|
where: { id: userId },
|
||||||
});
|
select: { credits: true },
|
||||||
if (!existing) {
|
});
|
||||||
const max = await tx.usersBadges.aggregate({
|
if (!buyer || buyer.credits < price) {
|
||||||
where: { userId },
|
outcome = "credits";
|
||||||
_max: { slotId: true },
|
} else {
|
||||||
});
|
// Atomically deduct credits and persist the badge so a failure
|
||||||
const slotId = (max._max.slotId ?? 0) + 1;
|
// between the two operations cannot orphan the user.
|
||||||
await tx.usersBadges.create({
|
if (price > 0) {
|
||||||
data: { userId, slotId, badgeCode: code },
|
await prisma.$transaction(
|
||||||
});
|
async (tx: Prisma.TransactionClient) => {
|
||||||
}
|
await tx.user.update({
|
||||||
});
|
where: { id: userId },
|
||||||
|
data: { credits: { decrement: price } },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant the badge live so it appears immediately for online users.
|
||||||
|
await rcon.giveBadge(userId, code).catch(() => {});
|
||||||
|
|
||||||
|
outcome = "bought";
|
||||||
|
boughtCode = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
|
||||||
await rcon.giveBadge(userId, code).catch(() => {});
|
|
||||||
|
|
||||||
outcome = "bought";
|
|
||||||
boughtCode = code;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,20 +1,21 @@
|
|||||||
"use server";
|
"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 { env } from "@/env";
|
||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { sendMail } from "@/lib/services/email";
|
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
|
// Token format: `{issuedAtUnix}.{hmacHex}` where
|
||||||
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
|
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
|
||||||
// server-only secret, an attacker who only knows the email cannot forge a
|
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
|
||||||
// matching token, and /verify can recompute + compare it without any storage.
|
// (bare 64-char hex) are rejected.
|
||||||
// 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).
|
|
||||||
|
|
||||||
/** 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 {
|
function verifySecret(): string {
|
||||||
const secret = env.APP_KEY || env.AUTH_SECRET;
|
const secret = env.APP_KEY || env.AUTH_SECRET;
|
||||||
if (!secret)
|
if (!secret)
|
||||||
@@ -24,26 +25,43 @@ function verifySecret(): string {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
function sign(email: string, issuedAt: number): string {
|
||||||
export async function verificationToken(email: string): Promise<string> {
|
return createHmac("sha256", verifySecret())
|
||||||
const normalised = email.trim().toLowerCase();
|
.update(`${email}|${issuedAt}`)
|
||||||
return createHash("sha256")
|
|
||||||
.update(`${normalised}|${verifySecret()}`)
|
|
||||||
.digest("hex");
|
.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`.
|
* Constant-time check that `token` matches a non-expired HMAC for `email`.
|
||||||
* Returns false on any length/format mismatch rather than throwing.
|
* Returns false on format/expiry/signature mismatch rather than throwing.
|
||||||
*/
|
*/
|
||||||
export async function isValidVerificationToken(
|
export async function isValidVerificationToken(
|
||||||
email: string,
|
email: string,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
if (!email || !token) return false;
|
||||||
const expected = await verificationToken(email);
|
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 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;
|
if (a.length !== b.length) return false;
|
||||||
return timingSafeEqual(a, b);
|
return timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
@@ -60,25 +78,42 @@ export async function sendVerification(email: string): Promise<boolean> {
|
|||||||
const base = env.APP_URL.replace(/\/+$/, "");
|
const base = env.APP_URL.replace(/\/+$/, "");
|
||||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
||||||
|
|
||||||
const hotelName =
|
const hotelName = await resolveHotelName();
|
||||||
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
|
||||||
|
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 = `
|
const html = `
|
||||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
|
||||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
<p>${escapeHtml(body)}</p>
|
||||||
<p style="margin:1.25rem 0">
|
<p style="margin:1.25rem 0">
|
||||||
<a href="${link}"
|
<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">
|
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>
|
</a>
|
||||||
</p>
|
</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>
|
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
||||||
</div>
|
</div>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
return sendMail(normalised, subject, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
|
|||||||
+64
-2
@@ -1,16 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
eventPrizeSchema,
|
eventPrizeSchema,
|
||||||
eventTypeSchema,
|
eventTypeSchema,
|
||||||
eventWinnerSchema,
|
eventWinnerSchema,
|
||||||
|
registerForEventSchema,
|
||||||
updateEventSchema,
|
updateEventSchema,
|
||||||
} from "@/lib/validators/event";
|
} from "@/lib/validators/event";
|
||||||
|
|
||||||
@@ -193,3 +195,63 @@ export const addEventWinner = adminAction(
|
|||||||
return actionOk({ id: winner.id });
|
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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
+73
-31
@@ -1,13 +1,39 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { isAllowed } from "@/lib/services/moderation";
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
||||||
const MESSAGE_MAX = 255;
|
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.
|
* Post a guestbook entry on a profile.
|
||||||
*
|
*
|
||||||
@@ -15,45 +41,61 @@ const MESSAGE_MAX = 255;
|
|||||||
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
||||||
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
|
* 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
|
* 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> {
|
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") ?? "")
|
const username = String(formData.get("username") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const now = new Date();
|
let outcome: GuestbookOutcome = "error";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteUserGuestbooks.create({
|
const session = await auth();
|
||||||
data: {
|
const userId = Number(session?.user?.id);
|
||||||
profileId,
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
userId,
|
redirect("/login");
|
||||||
message,
|
}
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
await clientIp();
|
||||||
},
|
if (!(await rateLimit(`guestbook:${userId}`, 5, 30_000)).ok) {
|
||||||
});
|
outcome = "ratelimit";
|
||||||
} catch {
|
} else {
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
const profileId = Number(formData.get("profileId"));
|
||||||
return;
|
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,
|
||||||
|
userId,
|
||||||
|
message,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "posted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username) revalidatePath(`/u/${username}`);
|
if (username) revalidatePath(`/u/${username}`);
|
||||||
|
guestbookRedirect(username, outcome);
|
||||||
}
|
}
|
||||||
+249
-39
@@ -1,60 +1,270 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||||
|
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||||
|
|
||||||
const ticketSchema = z.object({
|
const ticketSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(255),
|
title: z.string().min(1, "Title is required").max(255),
|
||||||
content: z.string().min(1, "Content is required").max(5000),
|
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> {
|
export async function createTicket(formData: FormData): Promise<void> {
|
||||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
let outcome: TicketOutcome = "error";
|
||||||
const session = await auth();
|
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
await clientIp();
|
|
||||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
|
||||||
|
|
||||||
const raw = {
|
|
||||||
title: String(formData.get("title") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
content: String(formData.get("content") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 5000),
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsed = ticketSchema.safeParse(raw);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
|
|
||||||
const { title, content } = parsed.data;
|
|
||||||
|
|
||||||
// Moderation check
|
|
||||||
try {
|
try {
|
||||||
await moderateOrThrow(`${title} ${content}`);
|
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||||
} catch {
|
const session = await auth();
|
||||||
return;
|
const userId = Number(session?.user?.id);
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
await clientIp();
|
||||||
|
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) {
|
||||||
|
outcome = "ratelimit";
|
||||||
|
} else {
|
||||||
|
const raw = {
|
||||||
|
title: String(formData.get("title") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255),
|
||||||
|
content: String(formData.get("content") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 5000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = ticketSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const { title, content } = parsed.data;
|
||||||
|
|
||||||
|
let moderated = false;
|
||||||
|
try {
|
||||||
|
await moderateOrThrow(`${title} ${content}`);
|
||||||
|
} catch {
|
||||||
|
moderated = true;
|
||||||
|
outcome = "moderated";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moderated) {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.websiteHelpCenterTickets.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
open: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "created";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await prisma.websiteHelpCenterTickets.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
open: true,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/help/tickets");
|
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);
|
||||||
}
|
}
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+330
-44
@@ -1,8 +1,44 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
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.
|
* Accept a pending friend request as the SIGNED-IN user.
|
||||||
@@ -16,60 +52,310 @@ import { prisma } from "@/lib/prisma";
|
|||||||
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
|
* 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
|
* create both inside a transaction and delete the originating request so it no
|
||||||
* longer shows as pending in the in-game messenger or here.
|
* 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> {
|
export async function acceptFriend(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: FriendOutcome = "error";
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The request must exist AND be addressed to the session user.
|
const session = await auth();
|
||||||
const request = await prisma.messengerFriendrequests.findUnique({
|
const meId = Number(session?.user?.id);
|
||||||
where: { id: requestId },
|
if (!Number.isInteger(meId) || meId <= 0) {
|
||||||
select: { id: true, userFromId: true, userToId: true },
|
redirect("/login");
|
||||||
});
|
|
||||||
if (!request || request.userToId !== meId) return;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendsSince = Math.floor(Date.now() / 1000);
|
await clientIp();
|
||||||
|
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
|
||||||
await prisma.$transaction(async (tx) => {
|
outcome = "ratelimit";
|
||||||
// Don't double-insert if a friendship already exists in either direction.
|
} else {
|
||||||
const existing = await tx.messengerFriendships.findFirst({
|
const requestId = Number(formData.get("requestId"));
|
||||||
where: {
|
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||||
OR: [
|
outcome = "invalid";
|
||||||
{ userOneId: meId, userTwoId: friendId },
|
} else {
|
||||||
{ userOneId: friendId, userTwoId: meId },
|
// The request must exist AND be addressed to the session user.
|
||||||
],
|
const request = await prisma.messengerFriendrequests.findUnique({
|
||||||
},
|
where: { id: requestId },
|
||||||
select: { id: true },
|
select: { id: true, userFromId: true, userToId: true },
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await tx.messengerFriendships.createMany({
|
|
||||||
data: [
|
|
||||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
|
||||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
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 — 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 tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
await prisma.$transaction(async (tx) => {
|
||||||
});
|
// Don't double-insert if a friendship already exists in either direction.
|
||||||
} catch {
|
const existing = await tx.messengerFriendships.findFirst({
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
where: {
|
||||||
return;
|
OR: [
|
||||||
|
{ userOneId: meId, userTwoId: friendId },
|
||||||
|
{ userOneId: friendId, userTwoId: meId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await tx.messengerFriendships.createMany({
|
||||||
|
data: [
|
||||||
|
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||||
|
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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("/messages");
|
||||||
revalidatePath("/friends");
|
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,8 +12,11 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
|
|
||||||
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
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(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({
|
const ticket = await prisma.supportTickets.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -42,7 +45,7 @@ const cfhStateSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateCfhState = adminAction(
|
export const updateCfhState = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
{ permission: CFH_PERM, schema: cfhStateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.supportTickets.findUnique({
|
const ticket = await prisma.supportTickets.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -68,7 +71,7 @@ export const updateCfhState = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeCfhTicket = adminAction(
|
export const closeCfhTicket = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await prisma.supportTickets.update({
|
await prisma.supportTickets.update({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -91,7 +94,7 @@ export const closeCfhTicket = adminAction(
|
|||||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.disconnectUser(ctx.data.userId);
|
await rcon.disconnectUser(ctx.data.userId);
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ const muteSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ export const quickMute = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.unmuteUser(ctx.data.userId);
|
await rcon.unmuteUser(ctx.data.userId);
|
||||||
|
|
||||||
@@ -150,7 +153,7 @@ const alertSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
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() });
|
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.kickAll(ctx.data.roomId);
|
await rcon.kickAll(ctx.data.roomId);
|
||||||
|
|
||||||
@@ -190,7 +193,7 @@ const broadcastSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.data.type === "hotel") {
|
if (ctx.data.type === "hotel") {
|
||||||
await rcon.hotelAlert(ctx.data.message);
|
await rcon.hotelAlert(ctx.data.message);
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ vi.mock("@/lib/services/email", () => ({
|
|||||||
sendMail: mockSendMail,
|
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", () => ({
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { env } from "@/env";
|
|||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { sendMail } from "@/lib/services/email";
|
import { sendMail } from "@/lib/services/email";
|
||||||
|
|
||||||
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
@@ -20,9 +21,19 @@ export async function requestReset(formData: FormData): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.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.
|
// 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))
|
const allowed = (await rateLimit(`reset:${ip}`, 3, 15 * 60_000)).ok;
|
||||||
.ok;
|
|
||||||
|
|
||||||
// Always respond the same way so we don't reveal which emails exist.
|
// Always respond the same way so we don't reveal which emails exist.
|
||||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||||
|
|||||||
+113
-2
@@ -1,15 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
updatePollSchema,
|
updatePollSchema,
|
||||||
|
voteOnPollSchema,
|
||||||
} from "@/lib/validators/poll";
|
} from "@/lib/validators/poll";
|
||||||
|
|
||||||
// ── Polls ───────────────────────────────────────────────────────────
|
// ── Polls ───────────────────────────────────────────────────────────
|
||||||
@@ -113,3 +115,112 @@ export const deletePollQuestion = adminAction(
|
|||||||
return actionOk();
|
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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
+64
-39
@@ -1,8 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// Column bounds from prisma/schema.prisma (radio_applications):
|
// Column bounds from prisma/schema.prisma (radio_applications):
|
||||||
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
|
// 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 TEXT_MAX = 5000;
|
||||||
const STYLE_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 {
|
function str(form: FormData, key: string, max: number): string {
|
||||||
return String(form.get(key) ?? "")
|
return String(form.get(key) ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -19,50 +38,56 @@ function str(form: FormData, key: string, max: number): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a radio DJ application.
|
* 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> {
|
export async function applyDj(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: ApplyOutcome = "error";
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
const realName = str(formData, "realName", NAME_MAX);
|
|
||||||
const availability = str(formData, "availability", TEXT_MAX);
|
|
||||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
|
||||||
const experience = str(formData, "experience", TEXT_MAX);
|
|
||||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioApplications.create({
|
const session = await auth();
|
||||||
data: {
|
const userId = Number(session?.user?.id);
|
||||||
userId: BigInt(userId),
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
realName,
|
redirect("/login");
|
||||||
age,
|
}
|
||||||
availability,
|
|
||||||
motivation,
|
await clientIp();
|
||||||
experience: experience || null,
|
if (!(await rateLimit(`radio-apply:${userId}`, 2, 300_000)).ok) {
|
||||||
musicStyle: musicStyle || null,
|
outcome = "ratelimit";
|
||||||
status: "pending",
|
} else {
|
||||||
createdAt: now,
|
const realName = str(formData, "realName", NAME_MAX);
|
||||||
updatedAt: now,
|
const availability = str(formData, "availability", TEXT_MAX);
|
||||||
},
|
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||||
});
|
const experience = str(formData, "experience", TEXT_MAX);
|
||||||
} catch {
|
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
|
||||||
return;
|
const ageRaw = Number(formData.get("age"));
|
||||||
|
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||||
|
|
||||||
|
if (!realName || !availability || !motivation || age <= 0) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.radioApplications.create({
|
||||||
|
data: {
|
||||||
|
userId: BigInt(userId),
|
||||||
|
realName,
|
||||||
|
age,
|
||||||
|
availability,
|
||||||
|
motivation,
|
||||||
|
experience: experience || null,
|
||||||
|
musicStyle: musicStyle || null,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "submitted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/radio/apply");
|
revalidatePath("/radio/apply");
|
||||||
|
applyRedirect(outcome);
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,75 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const SONG_MAX = 255;
|
const SONG_MAX = 255;
|
||||||
const ARTIST_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> {
|
export async function submitRequest(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: RequestOutcome = "error";
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
const songTitle = String(formData.get("songTitle") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, SONG_MAX);
|
|
||||||
const artist = String(formData.get("artist") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, ARTIST_MAX);
|
|
||||||
if (!songTitle && !artist) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
try {
|
try {
|
||||||
await prisma.radioSongRequests.create({
|
const session = await auth();
|
||||||
data: {
|
const userId = Number(session?.user?.id);
|
||||||
userId: BigInt(userId),
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
songTitle: songTitle || null,
|
redirect("/login");
|
||||||
artist: artist || null,
|
}
|
||||||
submittedAt: now,
|
|
||||||
createdAt: now,
|
await clientIp();
|
||||||
updatedAt: now,
|
if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) {
|
||||||
},
|
outcome = "ratelimit";
|
||||||
});
|
} else {
|
||||||
} catch {
|
const songTitle = String(formData.get("songTitle") ?? "")
|
||||||
return;
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, SONG_MAX);
|
||||||
|
const artist = String(formData.get("artist") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, ARTIST_MAX);
|
||||||
|
if (!songTitle && !artist) {
|
||||||
|
outcome = "empty";
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.radioSongRequests.create({
|
||||||
|
data: {
|
||||||
|
userId: BigInt(userId),
|
||||||
|
songTitle: songTitle || null,
|
||||||
|
artist: artist || null,
|
||||||
|
submittedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "posted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/radio/requests");
|
revalidatePath("/radio/requests");
|
||||||
|
requestsRedirect(outcome);
|
||||||
}
|
}
|
||||||
+70
-36
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -11,6 +12,23 @@ const shoutSchema = z.object({
|
|||||||
message: z.string().min(1, "Message is required").max(255),
|
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.
|
* Post a radio shout.
|
||||||
*
|
*
|
||||||
@@ -18,48 +36,64 @@ const shoutSchema = z.object({
|
|||||||
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
* trusted from the submitted FormData, so a crafted form cannot impersonate
|
||||||
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
|
* another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int
|
||||||
* session id is widened to BigInt for the insert.
|
* 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> {
|
export async function postShout(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
let outcome: ShoutOutcome = "error";
|
||||||
const userId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
await clientIp();
|
|
||||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
|
||||||
|
|
||||||
const raw = {
|
|
||||||
message: String(formData.get("message") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsed = shoutSchema.safeParse(raw);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
|
|
||||||
const { message } = parsed.data;
|
|
||||||
|
|
||||||
// Moderation check
|
|
||||||
try {
|
try {
|
||||||
await moderateOrThrow(message);
|
const session = await auth();
|
||||||
} catch {
|
const userId = Number(session?.user?.id);
|
||||||
return;
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
}
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
await clientIp();
|
||||||
try {
|
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) {
|
||||||
await prisma.radioShouts.create({
|
outcome = "ratelimit";
|
||||||
data: {
|
} else {
|
||||||
userId: BigInt(userId),
|
const raw = {
|
||||||
message,
|
message: String(formData.get("message") ?? "")
|
||||||
createdAt: now,
|
.normalize("NFC")
|
||||||
updatedAt: now,
|
.trim()
|
||||||
},
|
.slice(0, 255),
|
||||||
});
|
};
|
||||||
} catch {
|
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
const parsed = shoutSchema.safeParse(raw);
|
||||||
return;
|
if (!parsed.success) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const { message } = parsed.data;
|
||||||
|
|
||||||
|
let moderated = false;
|
||||||
|
try {
|
||||||
|
await moderateOrThrow(message);
|
||||||
|
} catch {
|
||||||
|
moderated = true;
|
||||||
|
outcome = "moderated";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moderated) {
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.radioShouts.create({
|
||||||
|
data: {
|
||||||
|
userId: BigInt(userId),
|
||||||
|
message,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outcome = "posted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/radio/shouts");
|
revalidatePath("/radio/shouts");
|
||||||
|
shoutsRedirect(outcome);
|
||||||
}
|
}
|
||||||
+95
-86
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
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 { rcon } from "@/lib/services/rcon";
|
||||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
|||||||
| "not_enough"
|
| "not_enough"
|
||||||
| "no_referrals"
|
| "no_referrals"
|
||||||
| "bad_config"
|
| "bad_config"
|
||||||
|
| "ratelimit"
|
||||||
| "error" = "error";
|
| "error" = "error";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -53,97 +54,105 @@ export async function claimReferral(_formData: FormData): Promise<void> {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
await clientIp();
|
||||||
// 5 referrals needed, 30 diamonds reward.
|
if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) {
|
||||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
outcome = "ratelimit";
|
||||||
prisma.websiteSetting
|
|
||||||
.findUnique({
|
|
||||||
where: { key: "referrals_needed" },
|
|
||||||
select: { value: true },
|
|
||||||
})
|
|
||||||
.catch(() => null),
|
|
||||||
prisma.websiteSetting
|
|
||||||
.findUnique({
|
|
||||||
where: { key: "referral_reward_amount" },
|
|
||||||
select: { value: true },
|
|
||||||
})
|
|
||||||
.catch(() => null),
|
|
||||||
// The seeded key is referral_reward_currency_type; fall back to the
|
|
||||||
// shorter referral_reward_currency name if that is what is configured.
|
|
||||||
prisma.websiteSetting
|
|
||||||
.findFirst({
|
|
||||||
where: {
|
|
||||||
key: {
|
|
||||||
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { value: true },
|
|
||||||
})
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
|
||||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
|
||||||
const currency = (currencyRaw?.value ?? "diamonds")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase() as CurrencyName;
|
|
||||||
|
|
||||||
// The user's referral tally lives in user_referrals (one row per user).
|
|
||||||
const referrals = await prisma.userReferrals
|
|
||||||
.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { id: true, referralsTotal: true },
|
|
||||||
orderBy: { id: "desc" },
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
|
||||||
|
|
||||||
if (!referrals || total <= 0) {
|
|
||||||
outcome = "no_referrals";
|
|
||||||
} else if (total < needed) {
|
|
||||||
outcome = "not_enough";
|
|
||||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
|
||||||
// Misconfigured reward — keep it conservative and grant nothing.
|
|
||||||
outcome = "bad_config";
|
|
||||||
} else {
|
} else {
|
||||||
// Spend the threshold first so a concurrent double-submit can't claim
|
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||||
// twice off the same balance, then deliver the reward and log it.
|
// 5 referrals needed, 30 diamonds reward.
|
||||||
await prisma.userReferrals.update({
|
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||||
where: { id: referrals.id },
|
prisma.websiteSetting
|
||||||
data: { referralsTotal: { decrement: needed } },
|
.findUnique({
|
||||||
});
|
where: { key: "referrals_needed" },
|
||||||
|
select: { value: true },
|
||||||
try {
|
|
||||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
|
||||||
} catch {
|
|
||||||
// sendCurrency already falls back to a direct DB write; if it still
|
|
||||||
// throws the spend stands. Roll the threshold back so the user isn't
|
|
||||||
// charged for an undelivered reward.
|
|
||||||
await prisma.userReferrals
|
|
||||||
.update({
|
|
||||||
where: { id: referrals.id },
|
|
||||||
data: { referralsTotal: { increment: needed } },
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => null),
|
||||||
outcome = "error";
|
prisma.websiteSetting
|
||||||
throw new Error("currency-delivery-failed");
|
.findUnique({
|
||||||
}
|
where: { key: "referral_reward_amount" },
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
// The seeded key is referral_reward_currency_type; fall back to the
|
||||||
|
// shorter referral_reward_currency name if that is what is configured.
|
||||||
|
prisma.websiteSetting
|
||||||
|
.findFirst({
|
||||||
|
where: {
|
||||||
|
key: {
|
||||||
|
in: [
|
||||||
|
"referral_reward_currency_type",
|
||||||
|
"referral_reward_currency",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
await prisma.claimedReferralLogs
|
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||||
.create({
|
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||||
data: {
|
const currency = (currencyRaw?.value ?? "diamonds")
|
||||||
userId,
|
.trim()
|
||||||
ipAddress: await clientIp(),
|
.toLowerCase() as CurrencyName;
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
// The user's referral tally lives in user_referrals (one row per user).
|
||||||
},
|
const referrals = await prisma.userReferrals
|
||||||
|
.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, referralsTotal: true },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => null);
|
||||||
// Best-effort audit log; the reward already landed.
|
|
||||||
|
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||||
|
|
||||||
|
if (!referrals || total <= 0) {
|
||||||
|
outcome = "no_referrals";
|
||||||
|
} else if (total < needed) {
|
||||||
|
outcome = "not_enough";
|
||||||
|
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||||
|
// Misconfigured reward — keep it conservative and grant nothing.
|
||||||
|
outcome = "bad_config";
|
||||||
|
} else {
|
||||||
|
// Spend the threshold first so a concurrent double-submit can't claim
|
||||||
|
// twice off the same balance, then deliver the reward and log it.
|
||||||
|
await prisma.userReferrals.update({
|
||||||
|
where: { id: referrals.id },
|
||||||
|
data: { referralsTotal: { decrement: needed } },
|
||||||
});
|
});
|
||||||
|
|
||||||
outcome = "claimed";
|
try {
|
||||||
|
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||||
|
} catch {
|
||||||
|
// sendCurrency already falls back to a direct DB write; if it still
|
||||||
|
// throws the spend stands. Roll the threshold back so the user isn't
|
||||||
|
// charged for an undelivered reward.
|
||||||
|
await prisma.userReferrals
|
||||||
|
.update({
|
||||||
|
where: { id: referrals.id },
|
||||||
|
data: { referralsTotal: { increment: needed } },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
outcome = "error";
|
||||||
|
throw new Error("currency-delivery-failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.claimedReferralLogs
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress: await clientIp(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Best-effort audit log; the reward already landed.
|
||||||
|
});
|
||||||
|
|
||||||
|
outcome = "claimed";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sendVerification } from "@/actions/email-verify";
|
import { sendVerification } from "@/actions/email-verify";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
@@ -54,7 +55,7 @@ export async function register(
|
|||||||
|
|
||||||
const parsed = registerSchema.safeParse(raw);
|
const parsed = registerSchema.safeParse(raw);
|
||||||
if (!parsed.success) {
|
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;
|
const { username, mail, password, look } = parsed.data;
|
||||||
@@ -100,6 +101,7 @@ export async function register(
|
|||||||
});
|
});
|
||||||
if (existing) return "That username is already taken";
|
if (existing) return "That username is already taken";
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.warn("Username uniqueness check failed during registration");
|
||||||
return "Registration is temporarily unavailable";
|
return "Registration is temporarily unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +124,13 @@ export async function register(
|
|||||||
try {
|
try {
|
||||||
await sendVerification(mail);
|
await sendVerification(mail);
|
||||||
} catch {
|
} catch {
|
||||||
// No-op: account is created; user can request a new link later.
|
logger.warn("Failed to send verification email after registration");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.warn("Account creation failed");
|
||||||
return "Could not create the account (is the username unique?)";
|
return "Could not create the account (is the username unique?)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasEmail) {
|
redirect("/login?registered=1");
|
||||||
redirect("/login?registered=1");
|
|
||||||
} else {
|
|
||||||
const { signIn } = await import("@/lib/auth");
|
|
||||||
await signIn("credentials", { username, password, redirect: false });
|
|
||||||
redirect("/verify?method=discord");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
|
|
||||||
const FAVICON_DIR = resolveMediaPath("favicon");
|
const FAVICON_DIR = resolveMediaPath("favicon");
|
||||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
|
|
||||||
const MEDIA_DIR = resolveMediaPath("logo");
|
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+311
-94
@@ -1,8 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
|
// 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
|
// 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 SUBJECT_MAX = 255;
|
||||||
const MESSAGE_MAX = 10000;
|
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.
|
* Send a friend request to another user.
|
||||||
*
|
*
|
||||||
@@ -19,52 +87,81 @@ const MESSAGE_MAX = 10000;
|
|||||||
*
|
*
|
||||||
* Writes into messenger_friendrequests (userFromId = requester, userToId =
|
* Writes into messenger_friendrequests (userFromId = requester, userToId =
|
||||||
* target). The emulator surfaces the pending request in the in-game messenger.
|
* 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> {
|
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 toId = Number(formData.get("userId"));
|
|
||||||
if (!Number.isInteger(toId) || toId <= 0) return;
|
|
||||||
|
|
||||||
// Can't befriend yourself.
|
|
||||||
if (toId === fromId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Guard against duplicate pending requests and already-existing friendships.
|
|
||||||
const [existingRequest, existingFriendship] = await Promise.all([
|
|
||||||
prisma.messengerFriendrequests.findFirst({
|
|
||||||
where: { userFromId: fromId, userToId: toId },
|
|
||||||
select: { id: true },
|
|
||||||
}),
|
|
||||||
prisma.messengerFriendships.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ userOneId: fromId, userTwoId: toId },
|
|
||||||
{ userOneId: toId, userTwoId: fromId },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (existingRequest || existingFriendship) return;
|
|
||||||
|
|
||||||
await prisma.messengerFriendrequests.create({
|
|
||||||
data: { userFromId: fromId, userToId: toId },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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") ?? "")
|
const username = String(formData.get("username") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
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 [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: [
|
||||||
|
{ userOneId: fromId, userTwoId: toId },
|
||||||
|
{ userOneId: toId, userTwoId: fromId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
outcome = "sent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
|
}
|
||||||
|
|
||||||
if (username) revalidatePath(`/u/${username}`);
|
if (username) revalidatePath(`/u/${username}`);
|
||||||
|
revalidatePath("/messages");
|
||||||
|
profileRedirect(username, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,67 +175,187 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
|
|||||||
* plus the opening post stored as the first comment (guilds_forums_comments).
|
* 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
|
* 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.
|
* 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> {
|
export async function postThread(formData: FormData): Promise<void> {
|
||||||
const session = await auth();
|
|
||||||
const openerId = Number(session?.user?.id);
|
|
||||||
if (!Number.isInteger(openerId) || openerId <= 0) return;
|
|
||||||
|
|
||||||
const guildId = Number(formData.get("guildId"));
|
const guildId = Number(formData.get("guildId"));
|
||||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
let outcome: ThreadOutcome = "error";
|
||||||
|
|
||||||
const subject = String(formData.get("subject") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, SUBJECT_MAX);
|
|
||||||
const message = String(formData.get("message") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, MESSAGE_MAX);
|
|
||||||
if (!subject || !message) return;
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Confirm the guild exists (and has a forum) before opening a thread.
|
const session = await auth();
|
||||||
const guild = await prisma.guilds.findUnique({
|
const openerId = Number(session?.user?.id);
|
||||||
where: { id: guildId },
|
if (!Number.isInteger(openerId) || openerId <= 0) {
|
||||||
select: { id: true },
|
redirect("/login");
|
||||||
});
|
}
|
||||||
if (!guild) return;
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
if (!Number.isInteger(guildId) || guildId <= 0) {
|
||||||
const thread = await tx.guildsForumsThreads.create({
|
outcome = "invalid";
|
||||||
data: {
|
} else {
|
||||||
guildId,
|
await clientIp();
|
||||||
openerId,
|
if (!(await rateLimit(`forum:${openerId}`, 3, 60_000)).ok) {
|
||||||
subject,
|
outcome = "ratelimit";
|
||||||
postsCount: 1,
|
} else {
|
||||||
createdAt: now,
|
const subject = String(formData.get("subject") ?? "")
|
||||||
updatedAt: now,
|
.normalize("NFC")
|
||||||
state: 0,
|
.trim()
|
||||||
pinned: 0,
|
.slice(0, SUBJECT_MAX);
|
||||||
locked: 0,
|
const message = String(formData.get("message") ?? "")
|
||||||
adminId: 0,
|
.normalize("NFC")
|
||||||
},
|
.trim()
|
||||||
select: { id: true },
|
.slice(0, MESSAGE_MAX);
|
||||||
});
|
if (!subject || !message) {
|
||||||
|
outcome = "invalid";
|
||||||
|
} else {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
await tx.guildsForumsComments.create({
|
const guild = await prisma.guilds.findUnique({
|
||||||
data: {
|
where: { id: guildId },
|
||||||
threadId: thread.id,
|
select: { id: true },
|
||||||
userId: openerId,
|
});
|
||||||
message,
|
if (!guild) {
|
||||||
createdAt: now,
|
outcome = "not_found";
|
||||||
state: 0,
|
} else {
|
||||||
adminId: 0,
|
await prisma.$transaction(async (tx) => {
|
||||||
},
|
const thread = await tx.guildsForumsThreads.create({
|
||||||
});
|
data: {
|
||||||
});
|
guildId,
|
||||||
} catch {
|
openerId,
|
||||||
// DB unavailable — fail soft; nothing to persist.
|
subject,
|
||||||
return;
|
postsCount: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
state: 0,
|
||||||
|
pinned: 0,
|
||||||
|
locked: 0,
|
||||||
|
adminId: 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.guildsForumsComments.create({
|
||||||
|
data: {
|
||||||
|
threadId: thread.id,
|
||||||
|
userId: openerId,
|
||||||
|
message,
|
||||||
|
createdAt: now,
|
||||||
|
state: 0,
|
||||||
|
adminId: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
outcome = "posted";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/guilds/${guildId}/forum`);
|
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
@@ -17,7 +17,12 @@ import {
|
|||||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||||
|
|
||||||
export const createTicket = authAction(
|
export const createTicket = authAction(
|
||||||
{ schema: createTicketSchema },
|
{
|
||||||
|
schema: createTicketSchema,
|
||||||
|
rateLimitKey: "ticket-create",
|
||||||
|
rateLimitMax: 5,
|
||||||
|
rateLimitWindowMs: 60_000,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.create({
|
const ticket = await prisma.websiteTicket.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -49,7 +54,12 @@ export const createTicket = authAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const userReplyTicket = authAction(
|
export const userReplyTicket = authAction(
|
||||||
{ schema: replyTicketSchema },
|
{
|
||||||
|
schema: replyTicketSchema,
|
||||||
|
rateLimitKey: "ticket-reply",
|
||||||
|
rateLimitMax: 10,
|
||||||
|
rateLimitWindowMs: 60_000,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeTicketByUser = 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) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -106,7 +121,10 @@ export const closeTicketByUser = authAction(
|
|||||||
// ── Admin actions ────────────────────────────────────────────────────
|
// ── Admin actions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const adminReplyTicket = adminAction(
|
export const adminReplyTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: replyTicketSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -146,7 +164,10 @@ export const adminReplyTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTicketStatus = adminAction(
|
export const updateTicketStatus = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: updateTicketStatusSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -181,7 +202,10 @@ export const updateTicketStatus = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const assignTicket = adminAction(
|
export const assignTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: assignTicketSchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -211,7 +235,10 @@ export const assignTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTicketPriority = adminAction(
|
export const updateTicketPriority = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
{
|
||||||
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
schema: updateTicketPrioritySchema,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { hash } from "bcryptjs";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
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");
|
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);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +304,7 @@ export const resetPassword = adminAction(
|
|||||||
.randomBytes(12)
|
.randomBytes(12)
|
||||||
.toString("base64url")
|
.toString("base64url")
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
const hashed = await hash(newPassword, 10);
|
const hashed = await hashPassword(newPassword);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: ctx.data.userId },
|
where: { id: ctx.data.userId },
|
||||||
@@ -388,6 +388,7 @@ export const muteUser = adminAction(
|
|||||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
|
void _target;
|
||||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
if (!success)
|
if (!success)
|
||||||
throw new ActionError("Failed to mute. Is the emulator running?");
|
throw new ActionError("Failed to mute. Is the emulator running?");
|
||||||
@@ -440,6 +441,7 @@ export const sendCredits = adminAction(
|
|||||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
|
void _target;
|
||||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||||
if (!success)
|
if (!success)
|
||||||
throw new ActionError("Failed to send credits. Is the emulator running?");
|
throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { sendCurrency } from "@/lib/services/send-currency";
|
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") ?? "")
|
const code = String(formData.get("code") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -1,29 +1,60 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { applyStaff } from "@/actions/applications";
|
import { applyStaff } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const t = await getTranslations("pages.applyStaff");
|
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 userId = Number(session.user.id);
|
||||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
const hotelName = await resolveHotelName();
|
||||||
|
|
||||||
function formatDeadline(d: Date | null | undefined): string {
|
|
||||||
if (!d) return t("noDeadline");
|
|
||||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Open positions ──────────────────────────────────────────
|
// ── Open positions ──────────────────────────────────────────
|
||||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||||
@@ -53,6 +84,17 @@ export default async function ApplyStaffPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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
|
<ContentCard
|
||||||
icon="🛡️"
|
icon="🛡️"
|
||||||
title={t("title", { hotel: hotelName })}
|
title={t("title", { hotel: hotelName })}
|
||||||
@@ -103,7 +145,13 @@ export default async function ApplyStaffPage() {
|
|||||||
<div className="content-card-body">
|
<div className="content-card-body">
|
||||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
{alreadyApplied ? (
|
{alreadyApplied ? (
|
||||||
|
|||||||
@@ -1,24 +1,59 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { applyTeam } from "@/actions/applications";
|
import { applyTeam } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const t = await getTranslations("pages.applyTeam");
|
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 userId = Number(session.user.id);
|
||||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
const hotelName = await resolveHotelName();
|
||||||
|
|
||||||
// ── Teams open for application ──────────────────────────────
|
// ── Teams open for application ──────────────────────────────
|
||||||
// The Prisma slice exposes teams directly (no separate team-position table
|
// The Prisma slice exposes teams directly (no separate team-position table
|
||||||
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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
|
<ContentCard
|
||||||
icon="🤝"
|
icon="🤝"
|
||||||
title={t("title", { hotel: hotelName })}
|
title={t("title", { hotel: hotelName })}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,15 +13,19 @@ export default async function BannedPage() {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
let reason = "";
|
let reason = "";
|
||||||
let expire = 0;
|
let expire = 0;
|
||||||
|
let hasBan = false;
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
try {
|
try {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const ban = await prisma.ban.findFirst({
|
const ban = await prisma.ban.findFirst({
|
||||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
where: {
|
||||||
orderBy: { banExpire: "desc" },
|
userId: Number(session.user.id),
|
||||||
|
...activeBanWhere(unixNow()),
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: "desc" },
|
||||||
select: { banReason: true, banExpire: true },
|
select: { banReason: true, banExpire: true },
|
||||||
});
|
});
|
||||||
if (ban) {
|
if (ban) {
|
||||||
|
hasBan = true;
|
||||||
reason = ban.banReason;
|
reason = ban.banReason;
|
||||||
expire = ban.banExpire;
|
expire = ban.banExpire;
|
||||||
}
|
}
|
||||||
@@ -29,17 +34,16 @@ export default async function BannedPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryText =
|
const expiryText = !hasBan
|
||||||
expire === 0
|
? ""
|
||||||
? ""
|
: expire === 0 || expire > FAR_FUTURE
|
||||||
: expire > FAR_FUTURE
|
? t("permanent")
|
||||||
? t("permanent")
|
: t("expires", {
|
||||||
: t("expires", {
|
date: new Date(expire * 1000)
|
||||||
date: new Date(expire * 1000)
|
.toISOString()
|
||||||
.toISOString()
|
.slice(0, 16)
|
||||||
.slice(0, 16)
|
.replace("T", " "),
|
||||||
.replace("T", " "),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
<main style={{ maxWidth: 560, margin: "2rem auto" }}>
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
|
|||||||
const ERROR_NOTE: Record<string, string> = {
|
const ERROR_NOTE: Record<string, string> = {
|
||||||
invalid: "That badge is no longer available.",
|
invalid: "That badge is no longer available.",
|
||||||
credits: "You don't have enough credits to buy this badge.",
|
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.",
|
fail: "Something went wrong. Please try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getErrorNote(error: string): string {
|
function getErrorNote(error: string): string {
|
||||||
if (error === "invalid") return ERROR_NOTE.invalid;
|
if (error === "invalid") return ERROR_NOTE.invalid;
|
||||||
if (error === "credits") return ERROR_NOTE.credits;
|
if (error === "credits") return ERROR_NOTE.credits;
|
||||||
|
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
|
||||||
return ERROR_NOTE.fail;
|
return ERROR_NOTE.fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../friends/loading";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requestReset } from "@/actions/password-reset";
|
import { requestReset } from "@/actions/password-reset";
|
||||||
|
import { CaptchaWidget } from "@/components/auth/captcha-widget";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
|
|
||||||
export default async function ForgotPage({
|
export default async function ForgotPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ sent?: string }>;
|
searchParams: Promise<{ sent?: string; error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.forgot");
|
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 (
|
return (
|
||||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||||
@@ -30,6 +35,25 @@ export default async function ForgotPage({
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
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">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("sendResetLink")}
|
{t("sendResetLink")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user