diff --git a/.env.example b/.env.example index 1587e7c0e0..9f0add1e5c 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,39 @@ DATABASE_URL=mysql://user:password@127.0.0.1:3306/atomcms # Optional pool tuning (defaults shown) DATABASE_POOL_SIZE=40 DATABASE_IDLE_TIMEOUT_MS=300000 +DATABASE_CONNECT_TIMEOUT_MS=10000 # Used by SSO ticket generation ({HOTEL_NAME}-{uuid}) HOTEL_NAME=Atom +APP_URL=http://localhost:3000 + +# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets +AUTH_SECRET= +APP_KEY= +CONVERT_PASSWORDS=false + +# RCON link to the Arcturus emulator +RCON_HOST=127.0.0.1 +RCON_PORT=3001 + +# Optional OAuth (enabled when both id+secret are set) +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Optional SMTP (password reset / alert emails) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= + +# Optional alerting (jobs worker / alert service) +DISCORD_WEBHOOK_URL= +ALERT_EMAIL= + +# Optional PayPal top-up (sandbox by default) +PAYPAL_CLIENT_ID= +PAYPAL_SECRET= +PAYPAL_API=https://api-m.sandbox.paypal.com diff --git a/package.json b/package.json index d2490e0ef2..2c8f86623e 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "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" }, "dependencies": { "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", "bcryptjs": "^3.0.2", + "croner": "^10.0.1", "hash-wasm": "^4.12.0", "next": "^16.2.9", "next-auth": "5.0.0-beta.31", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d57aa3bb0..4e8f4a9dff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: bcryptjs: specifier: ^3.0.2 version: 3.0.3 + croner: + specifier: ^10.0.1 + version: 10.0.1 hash-wasm: specifier: ^4.12.0 version: 4.12.0 @@ -1483,6 +1486,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3198,6 +3205,8 @@ snapshots: confbox@0.2.4: {} + croner@10.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts new file mode 100644 index 0000000000..38995ec528 --- /dev/null +++ b/scripts/jobs-worker.ts @@ -0,0 +1,165 @@ +/** + * Standalone cron worker for AtomCMS-Next. + * + * Replaces Laravel's scheduler (app/Console/Kernel.php + queue:work) with a + * single long-running process driven by `croner`. Run it OUTSIDE the Next.js + * request lifecycle (e.g. a separate container / pm2 process): + * + * pnpm jobs:worker # -> tsx scripts/jobs-worker.ts + * + * Each job body is wrapped in its own try/catch so one failing tick never + * tears down the scheduler; a thrown error or a falsy result is logged and the + * worker keeps ticking. Croner runs ticks serially per-job (protect: true) so a + * slow run can't overlap itself. + * + * DEPENDENCIES THIS FILE EXPECTS (orchestrator must provide them — noted here + * so the build isn't silently broken): + * - `croner` package (not yet in package.json): `pnpm add croner`. + * - `@/lib/services/alert` exporting `alert.emulatorOffline()` — a thin + * notifier (email/Discord) for emulator-down events. Not present yet; this + * worker is the first consumer. + */ +import "dotenv/config"; +import { Cron } from "croner"; +import { prisma } from "@/lib/prisma"; +import { rcon } from "@/lib/services/rcon"; +import { emulatorOffline } from "@/lib/services/alert"; + +// --- small logging helper (timestamped, namespaced) ------------------------ + +function log(scope: string, msg: string, ...rest: unknown[]): void { + console.log(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, ...rest); +} +function logErr(scope: string, msg: string, e: unknown): void { + console.error(`[jobs:${scope}] ${new Date().toISOString()} ${msg}`, e instanceof Error ? e.message : e); +} + +// --- (a) emulator health ping — every minute ------------------------------- +// +// Our RconClient has no `isConnected`: the TCP transport is fire-and-forget and +// resolves `false` on a dead/unreachable socket (it never throws for that). So +// we treat BOTH a thrown error AND a falsy `send()` result as "offline" and +// fire the alert. `data: null` matches the no-payload command shape AtomCMS +// uses for keep-alive style commands. + +async function pingEmulator(): Promise { + let online = false; + try { + online = await rcon.send("ping", null); + } catch (e) { + logErr("ping", "rcon.send threw", e); + online = false; + } + + if (online) { + log("ping", "emulator reachable"); + return; + } + + log("ping", "emulator unreachable — raising offline alert"); + try { + await emulatorOffline(); + } catch (e) { + // Never let the alert channel failing crash the tick. + logErr("ping", "emulatorOffline failed", e); + } +} + +// --- (b) maintenance:check — every minute ---------------------------------- +// +// website_maintenance_tasks has no scheduled-time column (id, userId, task, +// completed, timestamps), so "scheduled maintenance" == at least one row with +// completed = false. When such a task exists we flip the `maintenance_enabled` +// website_setting on; otherwise we clear it. The setting value is the canonical +// '1' / '0' string the rest of the CMS reads (see SiteSettings.getBool). +// +// If the model isn't present in the running schema (older DB), the prisma call +// throws and we skip — the catch keeps the worker alive. + +async function maintenanceCheck(): Promise { + let pending = 0; + try { + pending = await prisma.websiteMaintenanceTasks.count({ + where: { completed: false }, + }); + } catch (e) { + // Table absent / DB unreachable — skip this tick rather than thrashing. + logErr("maintenance", "could not read website_maintenance_tasks — skipping", e); + return; + } + + const desired = pending > 0 ? "1" : "0"; + + try { + const current = await prisma.websiteSetting.findUnique({ + where: { key: "maintenance_enabled" }, + select: { value: true }, + }); + + if (current?.value === desired) { + log("maintenance", `no change (maintenance_enabled=${desired}, pending=${pending})`); + return; + } + + await prisma.websiteSetting.upsert({ + where: { key: "maintenance_enabled" }, + update: { value: desired }, + create: { key: "maintenance_enabled", value: desired, comment: "Toggled by jobs-worker" }, + }); + log("maintenance", `maintenance_enabled -> ${desired} (pending tasks: ${pending})`); + } catch (e) { + logErr("maintenance", "failed to toggle maintenance_enabled", e); + } +} + +// --- (c) bans cleanup — hourly --------------------------------------------- +// +// Bans expire purely by the query-time `ban_expire > now()` filter applied +// wherever bans are read, so there's nothing to delete on a schedule. We keep +// the hourly tick for observability (and an obvious hook if a future hard-purge +// is ever wanted). + +async function bansCleanup(): Promise { + try { + const now = Math.floor(Date.now() / 1000); + const active = await prisma.ban.count({ where: { banExpire: { gt: now } } }); + log("bans", `cleanup is automatic via ban_expire>now filter — ${active} active ban(s), nothing to purge`); + } catch (e) { + logErr("bans", "count failed (non-fatal)", e); + } +} + +// --- scheduler wiring ------------------------------------------------------ + +const jobs: Cron[] = [ + new Cron("* * * * *", { name: "emulator-ping", protect: true }, pingEmulator), + new Cron("* * * * *", { name: "maintenance-check", protect: true }, maintenanceCheck), + new Cron("0 * * * *", { name: "bans-cleanup", protect: true }, bansCleanup), +]; + +log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`); + +// Run once immediately on boot so we don't wait up to a minute for first signal. +void pingEmulator(); +void maintenanceCheck(); + +// --- graceful shutdown ----------------------------------------------------- + +async function shutdown(signal: string): Promise { + log("worker", `received ${signal} — stopping jobs and disconnecting`); + for (const j of jobs) j.stop(); + try { + await prisma.$disconnect(); + } catch { + // ignore — we're exiting anyway + } + process.exit(0); +} + +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); + +// Don't let an unexpected async throw kill the whole worker. +process.on("unhandledRejection", (reason) => { + logErr("worker", "unhandledRejection", reason); +}); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index 1c7777dca1..03a2c9a53e 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { requireStaff } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; +import { logStaffActivity } from "@/lib/services/staff-activity"; type BanType = "account" | "ip" | "machine" | "super"; const BAN_TYPES: ReadonlySet = new Set(["account", "ip", "machine", "super"]); @@ -40,12 +41,26 @@ export async function createBan(formData: FormData): Promise { }); if (user) await rcon.disconnectUser(userId, user.username); + await logStaffActivity({ + staffId: staff.id, + action: "user_ban", + description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`, + targetType: "user", + targetId: userId, + }); revalidatePath("/admin/bans"); } export async function liftBan(formData: FormData): Promise { - await requireStaff(); + const staff = await requireStaff(); const id = Number(formData.get("id")); - if (id > 0) await prisma.ban.delete({ where: { id } }); + if (id > 0) { + await prisma.ban.delete({ where: { id } }); + await logStaffActivity({ + staffId: staff.id, + action: "ban_lift", + description: `Lifted ban #${id}`, + }); + } revalidatePath("/admin/bans"); } diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts new file mode 100644 index 0000000000..9df1e6a4b6 --- /dev/null +++ b/src/actions/admin-radio-extra.ts @@ -0,0 +1,233 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireStaff } from '@/lib/admin/guard'; +import { prisma } from '@/lib/prisma'; +import { siteSettings } from '@/lib/services/site-settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Parse a FormData field into a positive BigInt id, or null when invalid. */ +function parseId(raw: FormDataEntryValue | null): bigint | null { + if (typeof raw !== 'string' || raw.trim() === '') return null; + try { + const id = BigInt(raw.trim()); + return id > 0n ? id : null; + } catch { + return null; + } +} + +function str(raw: FormDataEntryValue | null): string { + return typeof raw === 'string' ? raw : ''; +} + +/** Checkbox/select truthiness: '1', 'true', 'on' → true. */ +function bool(raw: FormDataEntryValue | null): boolean { + const v = str(raw).trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'on'; +} + +// ── Radio settings (website_settings radio_* keys) ───────────────────────── + +/** + * Upsert one radio_* website_settings key. Mirrors AtomCMS's + * RadioSettings Filament page (key/value rows in website_settings). Busts the + * siteSettings cache so the public radio pages pick the change up immediately. + */ +export async function saveRadioSetting(formData: FormData): Promise { + await requireStaff(); + const key = str(formData.get('key')).trim().slice(0, 255); + const value = str(formData.get('value')); + const comment = str(formData.get('comment')).trim().slice(0, 255); + if (!key) return; + + try { + await prisma.websiteSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value, comment: comment || null }, + }); + siteSettings.reload(); + } catch { + // DB unavailable — fail soft so the action does not throw. + } + revalidatePath('/admin/radio/settings'); +} + +/** + * Bulk-save every radio_* field submitted by the settings form in one pass. + * The form posts a hidden `__keys` field listing the keys it rendered so we + * only touch those (and never wipe unrelated settings). + */ +export async function saveRadioSettings(formData: FormData): Promise { + await requireStaff(); + const keysRaw = str(formData.get('__keys')); + const keys = keysRaw + .split(',') + .map((k) => k.trim()) + .filter((k) => k.startsWith('radio_') || k.startsWith('auto_dj_')); + if (keys.length === 0) return; + + try { + await prisma.$transaction( + keys.map((key) => { + const value = str(formData.get(key)); + return prisma.websiteSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value, comment: null }, + }); + }), + ); + siteSettings.reload(); + } catch { + // Fail soft. + } + revalidatePath('/admin/radio/settings'); +} + +// ── Radio banners CRUD (radio_banners) ───────────────────────────────────── + +export async function createRadioBanner(formData: FormData): Promise { + const staff = await requireStaff(); + const imagePath = str(formData.get('imagePath')).trim().slice(0, 255); + if (!imagePath) return; + + const title = str(formData.get('title')).trim().slice(0, 255); + const description = str(formData.get('description')).trim(); + const sortOrderNum = Number(str(formData.get('sortOrder'))); + const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; + const isActive = bool(formData.get('isActive')); + const now = new Date(); + + try { + await prisma.radioBanners.create({ + data: { + userId: BigInt(staff.id), + imagePath, + title: title || null, + description: description || null, + sortOrder, + isActive, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // Fail soft. + } + revalidatePath('/admin/radio/banners'); +} + +export async function updateRadioBanner(formData: FormData): Promise { + await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + const imagePath = str(formData.get('imagePath')).trim().slice(0, 255); + const title = str(formData.get('title')).trim().slice(0, 255); + const description = str(formData.get('description')).trim(); + const sortOrderNum = Number(str(formData.get('sortOrder'))); + const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0; + const isActive = bool(formData.get('isActive')); + if (!imagePath) return; + + try { + await prisma.radioBanners.update({ + where: { id }, + data: { + imagePath, + title: title || null, + description: description || null, + sortOrder, + isActive, + updatedAt: new Date(), + }, + }); + } catch { + // Row may be gone; ignore. + } + revalidatePath('/admin/radio/banners'); +} + +export async function deleteRadioBanner(formData: FormData): Promise { + await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + try { + await prisma.radioBanners.delete({ where: { id } }); + } catch { + // Already deleted; ignore. + } + revalidatePath('/admin/radio/banners'); +} + +// ── Radio ranks CRUD (radio_ranks) ───────────────────────────────────────── + +export async function createRadioRank(formData: FormData): Promise { + await requireStaff(); + const name = str(formData.get('name')).trim().slice(0, 255); + if (!name) return; + + const description = str(formData.get('description')).trim().slice(0, 255); + const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255); + const isActive = bool(formData.get('isActive')); + const now = new Date(); + + try { + await prisma.radioRanks.create({ + data: { + name, + description: description || null, + badgeCode: badgeCode || null, + isActive, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // Fail soft. + } + revalidatePath('/admin/radio/ranks'); +} + +export async function updateRadioRank(formData: FormData): Promise { + await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + + const name = str(formData.get('name')).trim().slice(0, 255); + const description = str(formData.get('description')).trim().slice(0, 255); + const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255); + const isActive = bool(formData.get('isActive')); + if (!name) return; + + try { + await prisma.radioRanks.update({ + where: { id }, + data: { + name, + description: description || null, + badgeCode: badgeCode || null, + isActive, + updatedAt: new Date(), + }, + }); + } catch { + // Row may be gone; ignore. + } + revalidatePath('/admin/radio/ranks'); +} + +export async function deleteRadioRank(formData: FormData): Promise { + await requireStaff(); + const id = parseId(formData.get('id')); + if (id === null) return; + try { + await prisma.radioRanks.delete({ where: { id } }); + } catch { + // Already deleted; ignore. + } + revalidatePath('/admin/radio/ranks'); +} diff --git a/src/actions/admin-users.ts b/src/actions/admin-users.ts index afcf2518e5..2c06658b3b 100644 --- a/src/actions/admin-users.ts +++ b/src/actions/admin-users.ts @@ -5,16 +5,24 @@ import { requireStaff } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; +import { logStaffActivity } from "@/lib/services/staff-activity"; const CURRENCIES: ReadonlySet = new Set(["credits", "duckets", "diamonds", "points"]); export async function giveCurrency(formData: FormData): Promise { - await requireStaff(); + const staff = await requireStaff(); const userId = Number(formData.get("userId")); const type = String(formData.get("type")); const amount = Number(formData.get("amount")); if (userId > 0 && amount > 0 && CURRENCIES.has(type)) { await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount); + await logStaffActivity({ + staffId: staff.id, + action: "give_currency", + description: `Gave ${amount} ${type} to user #${userId}`, + targetType: "user", + targetId: userId, + }); } revalidatePath(`/admin/users/${userId}`); } @@ -31,12 +39,19 @@ export async function setMotto(formData: FormData): Promise { } export async function setRank(formData: FormData): Promise { - await requireStaff(); + const staff = await requireStaff(); const userId = Number(formData.get("userId")); const rank = Number(formData.get("rank")); if (userId > 0 && rank > 0) { await prisma.user.update({ where: { id: userId }, data: { rank } }); await rcon.setRank(userId, rank); + await logStaffActivity({ + staffId: staff.id, + action: "rank_change", + description: `Set rank of user #${userId} to ${rank}`, + targetType: "user", + targetId: userId, + }); } revalidatePath(`/admin/users/${userId}`); } diff --git a/src/actions/applications.ts b/src/actions/applications.ts new file mode 100644 index 0000000000..f607579929 --- /dev/null +++ b/src/actions/applications.ts @@ -0,0 +1,92 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// AtomCMS validates the application body with `min:10`. Mirror that floor and +// cap the write defensively (column is TEXT, but we keep applications sane). +const CONTENT_MIN = 10; +const CONTENT_MAX = 5000; + +/** + * 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 { + const session = await auth(); + 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") ?? "").trim().slice(0, CONTENT_MAX); + if (content.length < CONTENT_MIN) return; + + try { + // Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition). + const existing = await prisma.websiteStaffApplications.findFirst({ + where: { userId, rankId }, + select: { id: true }, + }); + if (existing) return; + + const now = new Date(); + await prisma.websiteStaffApplications.create({ + data: { userId, rankId, content, createdAt: now, updatedAt: now }, + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + revalidatePath("/apply/staff"); +} + +/** + * 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 { + const session = await auth(); + 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") ?? "").trim().slice(0, CONTENT_MAX); + if (content.length < CONTENT_MIN) return; + + try { + const existing = await prisma.websiteStaffApplications.findFirst({ + where: { userId, rankId }, + select: { id: true }, + }); + if (existing) return; + + const now = new Date(); + await prisma.websiteStaffApplications.create({ + data: { userId, rankId, content, createdAt: now, updatedAt: now }, + }); + } catch { + return; + } + + revalidatePath("/apply/team"); +} diff --git a/src/actions/radio-apply.ts b/src/actions/radio-apply.ts new file mode 100644 index 0000000000..c32df80086 --- /dev/null +++ b/src/actions/radio-apply.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Column bounds from prisma/schema.prisma (radio_applications): +// real_name VARCHAR(255); the rest are TEXT. age is an INT. +const NAME_MAX = 255; +const TEXT_MAX = 5000; +const STYLE_MAX = 5000; + +function str(form: FormData, key: string, max: number): string { + return String(form.get(key) ?? "").trim().slice(0, max); +} + +/** + * 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 { + const session = await auth(); + 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 { + await prisma.radioApplications.create({ + data: { + userId: BigInt(userId), + realName, + age, + availability, + motivation, + experience: experience || null, + musicStyle: musicStyle || null, + status: "pending", + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // DB unavailable or duplicate — fail soft; nothing to persist. + return; + } + + revalidatePath("/radio/apply"); +} diff --git a/src/actions/radio-shouts.ts b/src/actions/radio-shouts.ts new file mode 100644 index 0000000000..0e84c65ca3 --- /dev/null +++ b/src/actions/radio-shouts.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// radio_shouts.message is TEXT, but cap the write to keep shouts tweet-sized. +const MESSAGE_MAX = 255; + +/** + * Post a radio shout. + * + * The AUTHOR (userId) is re-read from the session via auth() and is never + * trusted from the submitted FormData, so a crafted form cannot impersonate + * another account. radio_shouts.user_id is an UNSIGNED BIGINT, so the Int + * session id is widened to BigInt for the insert. + */ +export async function postShout(formData: FormData): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) return; + + const message = String(formData.get("message") ?? "") + .trim() + .slice(0, MESSAGE_MAX); + if (!message) return; + + const now = new Date(); + try { + await prisma.radioShouts.create({ + data: { + userId: BigInt(userId), + message, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + // DB unavailable — fail soft; nothing to persist. + return; + } + + revalidatePath("/radio/shouts"); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 0a10515946..0c50775359 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,12 +1,23 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import type { ReactNode } from "react"; import { requireStaff } from "@/lib/admin/guard"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; export default async function AdminLayout({ children }: { children: ReactNode }) { const staff = await requireStaff(); + // Force staff 2FA before the admin panel (AtomCMS ForceStaffTwoFactorMiddleware). + if (await siteSettings.getBool("force_staff_2fa", false)) { + const u = await prisma.user + .findUnique({ where: { id: staff.id }, select: { twoFactorConfirmedAt: true } }) + .catch(() => null); + if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired"); + } + return (