diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 38995ec5..79ae56a8 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -22,8 +22,10 @@ import "dotenv/config"; import { Cron } from "croner"; import { prisma } from "@/lib/prisma"; -import { rcon } from "@/lib/services/rcon"; import { emulatorOffline } from "@/lib/services/alert"; +import { fetchNowPlaying } from "@/lib/services/radio"; +import { rcon } from "@/lib/services/rcon"; +import { siteSettings } from "@/lib/services/site-settings"; // --- small logging helper (timestamped, namespaced) ------------------------ @@ -129,12 +131,129 @@ async function bansCleanup(): Promise { } } +// --- (d) radio: record song plays — every 30s ------------------------------ +// +// Polls the configured now-playing endpoint (AtomCMS radio:record-songs). On a +// track CHANGE it appends a row to radio_song_plays so the public history + +// admin pages have data. De-dups against the most recent recorded play. + +let lastRecordedTitle: string | null = null; + +async function recordSongPlay(): Promise { + let np: Awaited>; + try { + np = await fetchNowPlaying(); + } catch (e) { + logErr("radio", "now-playing fetch threw", e); + return; + } + if (!np || !np.title) return; + if (np.title === lastRecordedTitle) return; + + try { + const latest = await prisma.radioSongPlays.findFirst({ + orderBy: { id: "desc" }, + select: { title: true }, + }); + if (latest?.title === np.title) { + lastRecordedTitle = np.title; + return; + } + await prisma.radioSongPlays.create({ + data: { title: np.title, artist: np.artist, playedAt: new Date(), createdAt: new Date() }, + }); + lastRecordedTitle = np.title; + log("radio", `recorded play: ${np.artist ? `${np.artist} - ` : ""}${np.title}`); + } catch (e) { + logErr("radio", "could not record song play", e); + } +} + +// --- (e) radio: auto-DJ rotation — every minute ---------------------------- +// +// When no live DJ is broadcasting (radio_current_dj_id empty) and auto-DJ is +// enabled, advances the radio_auto_dj_playlist by sort_order and writes the +// current track to the radio_now_playing setting (read by the player/API). + +async function autoDj(): Promise { + try { + if (!(await siteSettings.getBool("radio_auto_dj_enabled", false))) return; + const liveDj = (await siteSettings.get("radio_current_dj_id", "")) ?? ""; + if (liveDj) return; // a real DJ is on air — don't override + + const tracks = await prisma.radioAutoDjPlaylist.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + select: { id: true, title: true, artist: true, playCount: true }, + }); + if (tracks.length === 0) return; + + // Pick the least-recently-played (lowest playCount, then lowest id). + const next = tracks.slice().sort((a, b) => a.playCount - b.playCount || Number(a.id - b.id))[0]; + await prisma.radioAutoDjPlaylist.update({ + where: { id: next.id }, + data: { playCount: { increment: 1 }, lastPlayedAt: new Date() }, + }); + const label = `${next.artist ? `${next.artist} - ` : ""}${next.title}`; + await prisma.websiteSetting.upsert({ + where: { key: "radio_now_playing" }, + update: { value: label }, + create: { key: "radio_now_playing", value: label, comment: "Auto-DJ now playing" }, + }); + log("radio", `auto-DJ now playing: ${label}`); + } catch (e) { + logErr("radio", "auto-DJ tick failed", e); + } +} + +// --- (f) github: update check — hourly ------------------------------------- +// +// Compares the latest commit on the configured GitHub repo against the last one +// seen, and stores update_available + update_latest_sha settings the admin +// dashboard can surface. No-ops unless github_repo (owner/repo) is set. + +async function githubUpdateCheck(): Promise { + try { + const repo = (await siteSettings.get("github_repo", "")) ?? ""; + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) return; + const branch = (await siteSettings.get("github_branch", "main")) ?? "main"; + + const res = await fetch(`https://api.github.com/repos/${repo}/commits/${branch}`, { + headers: { accept: "application/vnd.github+json", "user-agent": "atomcms-next" }, + cache: "no-store", + }); + if (!res.ok) return; + const data = (await res.json()) as { sha?: string }; + const sha = data?.sha; + if (!sha) return; + + const known = (await siteSettings.get("update_current_sha", "")) ?? ""; + const available = known ? known !== sha ? "1" : "0" : "0"; + for (const [key, value] of [ + ["update_latest_sha", sha], + ["update_available", available], + ] as const) { + await prisma.websiteSetting.upsert({ + where: { key }, + update: { value }, + create: { key, value, comment: "GitHub update check" }, + }); + } + log("github", `latest ${sha.slice(0, 7)} (update ${available === "1" ? "AVAILABLE" : "none"})`); + } catch (e) { + logErr("github", "update check failed", 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), + new Cron("*/30 * * * * *", { name: "radio-record-songs", protect: true }, recordSongPlay), + new Cron("* * * * *", { name: "radio-auto-dj", protect: true }, autoDj), + new Cron("0 * * * *", { name: "github-update-check", protect: true }, githubUpdateCheck), ]; log("worker", `started — ${jobs.length} scheduled job(s): ${jobs.map((j) => j.name).join(", ")}`); diff --git a/src/actions/register.ts b/src/actions/register.ts index bccf4e29..6cf1795a 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -1,11 +1,13 @@ "use server"; -import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; +import { checkVpn } from "@/lib/services/ip-lookup"; +import { siteSettings } from "@/lib/services/site-settings"; const USERNAME_RE = /^[A-Za-z0-9_\-=?!@:.,]{3,25}$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; @@ -17,15 +19,41 @@ export async function register(formData: FormData): Promise { const mail = String(formData.get("mail") ?? "").trim().toLowerCase(); const password = String(formData.get("password") ?? ""); + const ip = await clientIp(); + let error: string | null = null; if (!USERNAME_RE.test(username)) error = "Username must be 3-25 valid characters"; else if (password.length < 6) error = "Password must be at least 6 characters"; else if (!EMAIL_RE.test(mail)) error = "Enter a valid email address"; // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. + if (!error && !rateLimit(`register:${ip}`, 5, 10 * 60_000).ok) { + error = "Too many sign-up attempts. Please wait a few minutes and try again."; + } + + // CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings. if (!error) { - const limit = rateLimit(`register:${await clientIp()}`, 5, 10 * 60_000); - if (!limit.ok) error = "Too many sign-up attempts. Please wait a few minutes and try again."; + const cfg = await captchaConfig(); + if (cfg.provider !== "none") { + const token = String(formData.get(cfg.field) ?? ""); + if (!(await verifyCaptcha(token, ip))) error = "Captcha verification failed. Please try again."; + } + } + + // VPN/proxy block (only when enabled in /admin/vpn). + if (!error && (await checkVpn(ip)).blocked) { + error = + (await siteSettings.get("vpn_block_message", "")) || + "Registrations from VPN/proxy connections are not allowed."; + } + + // Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS. + if (!error) { + const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0; + if (max > 0) { + const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0); + if (count >= max) error = "You have reached the maximum number of accounts for your connection."; + } } // Uniqueness check (kept out of the success path's try so NEXT_REDIRECT propagates). @@ -42,9 +70,6 @@ export async function register(formData: FormData): Promise { } if (!error) { - const h = await headers(); - const ip = - h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0"; const now = Math.floor(Date.now() / 1000); try { const created = await prisma.user.create({ diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts new file mode 100644 index 00000000..f457ed41 --- /dev/null +++ b/src/app/api/articles/[slug]/route.ts @@ -0,0 +1,40 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/articles/:slug — single website_article by slug, including the + * fullStory body. Returns { error } (404) when the slug is unknown. + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + try { + const article = await prisma.websiteArticles.findUnique({ + where: { slug }, + select: { + id: true, + title: true, + slug: true, + shortStory: true, + fullStory: true, + image: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!article) { + return apiJson({ error: "Article not found" }, { status: 404 }); + } + + return apiJson({ data: article }); + } catch { + // DB unavailable — treat as not found rather than a 500. + return apiJson({ error: "Article not found" }, { status: 200 }); + } +} diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts new file mode 100644 index 00000000..006f4c1a --- /dev/null +++ b/src/app/api/articles/route.ts @@ -0,0 +1,49 @@ +import { apiJson, pagination } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/articles — paginated list of website_articles, newest first. + * Mirrors the /news page query (prisma.websiteArticles). Returns list cards + * (shortStory only, never fullStory) plus pagination metadata. + */ +export async function GET(req: Request) { + const sp = new URL(req.url).searchParams; + const { page, perPage, skip, take } = pagination(sp); + + try { + const [total, articles] = await Promise.all([ + prisma.websiteArticles.count(), + prisma.websiteArticles.findMany({ + select: { + id: true, + title: true, + slug: true, + shortStory: true, + image: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + skip, + take, + }), + ]); + + return apiJson({ + data: articles, + meta: { + page, + perPage, + total, + lastPage: Math.max(1, Math.ceil(total / perPage)), + }, + }); + } catch { + // DB unavailable — return an empty payload instead of a 500. + return apiJson( + { data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, + { status: 200 }, + ); + } +} diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts new file mode 100644 index 00000000..ea914e71 --- /dev/null +++ b/src/app/api/home/route.ts @@ -0,0 +1,40 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/home — combined landing payload: the latest 4 website_articles and + * the current online player count (users.online === "1"). Mirrors the queries + * used by the public pages and the admin dashboard. + */ +export async function GET(_req: Request) { + let articles: unknown[] = []; + let online = 0; + let hotelName = "Atom"; + + try { + [articles, online, hotelName] = await Promise.all([ + prisma.websiteArticles.findMany({ + select: { + id: true, + title: true, + slug: true, + shortStory: true, + image: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + take: 4, + }), + prisma.user.count({ where: { online: "1" } }), + siteSettings.get("hotel_name", "Atom").then((v) => v ?? "Atom"), + ]); + + return apiJson({ articles, online, hotelName }); + } catch { + // DB unavailable — return an empty payload instead of a 500. + return apiJson({ articles: [], online: 0, hotelName }, { status: 200 }); + } +} diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts new file mode 100644 index 00000000..29115dfd --- /dev/null +++ b/src/app/api/leaderboard/route.ts @@ -0,0 +1,73 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +// Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx. +// AtomCMS-faithful currency type ids (see prisma/schema.prisma UsersCurrency): +// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5. +export const dynamic = "force-dynamic"; + +type LeaderboardType = "credits" | "diamonds" | "duckets"; + +const CURRENCY_TYPE: Record, number> = { + diamonds: 5, + duckets: 0, +}; + +type Row = { rank: number; username: string; look: string; value: number }; + +async function loadCreditsRows(): Promise { + const users = await prisma.user.findMany({ + orderBy: { credits: "desc" }, + take: 20, + select: { username: true, look: true, credits: true }, + }); + return users.map((u, i) => ({ + rank: i + 1, + username: u.username, + look: u.look, + value: u.credits, + })); +} + +async function loadCurrencyRows(type: number): Promise { + const top = await prisma.usersCurrency.findMany({ + where: { type }, + orderBy: { amount: "desc" }, + take: 20, + select: { userId: true, amount: true }, + }); + if (top.length === 0) return []; + + const users = await prisma.user.findMany({ + where: { id: { in: top.map((t) => t.userId) } }, + select: { id: true, username: true, look: true }, + }); + const byId = new Map(users.map((u) => [u.id, u])); + + return top + .map((t) => { + const u = byId.get(t.userId); + if (!u) return null; + return { username: u.username, look: u.look, value: t.amount }; + }) + .filter((r): r is Omit => r !== null) + .map((r, i) => ({ rank: i + 1, ...r })); +} + +export async function GET(req: Request) { + try { + const sp = new URL(req.url).searchParams; + const requested = sp.get("type"); + const type: LeaderboardType = + requested === "diamonds" || requested === "duckets" ? requested : "credits"; + + const rows = + type === "credits" + ? await loadCreditsRows() + : await loadCurrencyRows(CURRENCY_TYPE[type]); + + return apiJson({ type, data: rows }, { status: 200 }); + } catch { + return apiJson({ type: "credits", data: [] }, { status: 200 }); + } +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 00000000..ae4e4f36 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,60 @@ +// Public REST API — the currently signed-in user. +// +// Reads the NextAuth session, then re-queries prisma.user by the session id to +// return a safe field set (never password / auth_ticket / 2FA secrets / pincode +// / mail). Returns { user: null } when unauthenticated or on DB failure. + +import { apiJson } from "@/lib/api"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + const session = await auth(); + const id = session?.user?.id ? Number(session.user.id) : null; + if (!id || Number.isNaN(id)) { + return apiJson({ user: null }); + } + + try { + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + username: true, + look: true, + motto: true, + rank: true, + credits: true, + pixels: true, + points: true, + gender: true, + online: true, + accountCreated: true, + }, + }); + + if (!user) { + return apiJson({ user: null }); + } + + return apiJson({ + user: { + id: user.id, + username: user.username, + look: user.look, + motto: user.motto, + rank: user.rank, + credits: user.credits, + pixels: user.pixels, + points: user.points, + gender: user.gender, + online: user.online === "1", + accountCreated: user.accountCreated, + }, + }); + } catch { + return apiJson({ user: null }); + } +} diff --git a/src/app/api/online/count/route.ts b/src/app/api/online/count/route.ts new file mode 100644 index 00000000..928e451c --- /dev/null +++ b/src/app/api/online/count/route.ts @@ -0,0 +1,21 @@ +// Public REST API — count of currently-online users. +// +// `online` is the emulator's string flag "1" / "0" (see User model). Returns +// { count: 0 } (never 500) on DB failure. + +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const count = await prisma.user.count({ + where: { online: "1" }, + }); + + return apiJson({ count }); + } catch { + return apiJson({ count: 0 }); + } +} diff --git a/src/app/api/online/route.ts b/src/app/api/online/route.ts new file mode 100644 index 00000000..b04268e8 --- /dev/null +++ b/src/app/api/online/route.ts @@ -0,0 +1,24 @@ +// Public REST API — list of currently-online users (username + look only). +// +// `online` is stored by the emulator as the string "1" / "0" (see User model in +// prisma/schema.prisma). Capped at 100 rows. Returns empty data (never 500) on +// DB failure. + +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const users = await prisma.user.findMany({ + where: { online: "1" }, + select: { username: true, look: true }, + take: 100, + }); + + return apiJson({ users }); + } catch { + return apiJson({ users: [] }); + } +} diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts new file mode 100644 index 00000000..01fd8acd --- /dev/null +++ b/src/app/api/photos/route.ts @@ -0,0 +1,47 @@ +import { apiJson, pagination } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/photos — recent community photos (camera_web), newest first. + * Mirrors the /photos page query (prisma.cameraWeb). Returns id, userId, url + * and timestamp plus pagination metadata. + */ +export async function GET(req: Request) { + const sp = new URL(req.url).searchParams; + const { page, perPage, skip, take } = pagination(sp); + + try { + const [total, photos] = await Promise.all([ + prisma.cameraWeb.count(), + prisma.cameraWeb.findMany({ + select: { + id: true, + userId: true, + url: true, + timestamp: true, + }, + orderBy: { timestamp: "desc" }, + skip, + take, + }), + ]); + + return apiJson({ + data: photos, + meta: { + page, + perPage, + total, + lastPage: Math.max(1, Math.ceil(total / perPage)), + }, + }); + } catch { + // DB unavailable — return an empty payload instead of a 500. + return apiJson( + { data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, + { status: 200 }, + ); + } +} diff --git a/src/app/api/radio/config/route.ts b/src/app/api/radio/config/route.ts new file mode 100644 index 00000000..4bafe123 --- /dev/null +++ b/src/app/api/radio/config/route.ts @@ -0,0 +1,45 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +// Radio player config: the subset of radio_* website_settings the front-end +// player needs (stream URL, name, autoplay, enabled, widget visibility) as a +// flat { key: value } map. No secrets live among these keys. +export const dynamic = "force-dynamic"; + +// radio_* keys relevant to the public player widget. Mirrors what the AtomCMS +// radio-player blade requests from /api/radio/config. +const CONFIG_KEYS = new Set([ + "radio_enabled", + "radio_name", + "radio_stream_url", + "radio_stream_backup_url", + "radio_auto_play", + "radio_auto_play_delay", + "radio_mute_on_start", + "radio_volume", + "radio_style", + "radio_player_type", + "radio_logo_url", + "radio_widget_enabled", + "radio_widget_show_globally", + "radio_widget_position", +]); + +export async function GET(_req: Request) { + try { + const rows = await prisma.websiteSetting.findMany({ + where: { key: { in: Array.from(CONFIG_KEYS) } }, + select: { key: true, value: true }, + }); + + const config: Record = {}; + for (const row of rows) { + config[row.key] = row.value; + } + + return apiJson(config); + } catch { + // DB unavailable — serve an empty config rather than a 500. + return apiJson({}, { status: 200 }); + } +} diff --git a/src/app/api/radio/listeners/route.ts b/src/app/api/radio/listeners/route.ts new file mode 100644 index 00000000..ef6181e5 --- /dev/null +++ b/src/app/api/radio/listeners/route.ts @@ -0,0 +1,89 @@ +import { apiJson } from "@/lib/api"; +import { siteSettings } from "@/lib/services/site-settings"; + +// Proxy for the configured radio "listeners" provider. The provider URL is +// stored in radio_listeners_api_url; we fetch it server-side with a short, hard +// timeout and reduce the response to a single listener count. +export const dynamic = "force-dynamic"; + +const FETCH_TIMEOUT_MS = 4000; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +// Best-effort listener-count extraction across the common provider shapes +// (AzureCast nests under listeners.current/total; others expose num_listeners, +// listeners, unique_listeners, count, or a bare number). +function findCount(value: unknown, depth = 0): number | null { + if (depth > 4) return null; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) { + return Number(value); + } + if (!isRecord(value)) return null; + + const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"]; + for (const key of keys) { + const v = value[key]; + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) { + return Number(v); + } + } + for (const v of Object.values(value)) { + if (isRecord(v)) { + const found = findCount(v, depth + 1); + if (found !== null) return found; + } + } + return null; +} + +export async function GET(_req: Request) { + let url: string | null = null; + try { + url = (await siteSettings.get("radio_listeners_api_url", "")) || null; + } catch { + url = null; + } + + // Not configured — no listener data available. + if (!url) { + return apiJson({ listeners: null }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: controller.signal, + cache: "no-store", + headers: { accept: "application/json, text/plain, */*" }, + }); + + const raw = (await res.text()).trim(); + if (raw === "") { + return apiJson({ listeners: null }); + } + + let parsed: unknown = raw; + try { + parsed = JSON.parse(raw); + } catch { + // leave as raw string; findCount handles numeric strings. + } + + return apiJson({ listeners: findCount(parsed) }); + } catch (e) { + const aborted = e instanceof Error && e.name === "AbortError"; + return apiJson({ + listeners: null, + error: aborted + ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` + : "Fetch failed", + }); + } finally { + clearTimeout(timer); + } +} diff --git a/src/app/api/radio/now-playing/route.ts b/src/app/api/radio/now-playing/route.ts new file mode 100644 index 00000000..81ea969d --- /dev/null +++ b/src/app/api/radio/now-playing/route.ts @@ -0,0 +1,54 @@ +import { apiJson } from "@/lib/api"; +import { siteSettings } from "@/lib/services/site-settings"; + +// Proxy for the configured radio "now playing" provider. The provider URL is +// stored in radio_now_playing_api_url; we fetch it server-side (never exposing +// the URL or any provider key to the browser) with a short, hard timeout. +export const dynamic = "force-dynamic"; + +const FETCH_TIMEOUT_MS = 4000; + +export async function GET(_req: Request) { + let url: string | null = null; + try { + url = (await siteSettings.get("radio_now_playing_api_url", "")) || null; + } catch { + url = null; + } + + // Not configured — there is nothing to play. + if (!url) { + return apiJson({ nowPlaying: null }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: controller.signal, + cache: "no-store", + headers: { accept: "application/json, text/plain, */*" }, + }); + + const raw = (await res.text()).trim(); + if (raw === "") { + return apiJson({ nowPlaying: null }); + } + + // Return parsed JSON when the provider speaks JSON, else the raw text body. + try { + return apiJson(JSON.parse(raw)); + } catch { + return apiJson({ nowPlaying: raw.slice(0, 2000) }); + } + } catch (e) { + const aborted = e instanceof Error && e.name === "AbortError"; + return apiJson({ + error: aborted + ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` + : "Fetch failed", + }); + } finally { + clearTimeout(timer); + } +} diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts new file mode 100644 index 00000000..4c345237 --- /dev/null +++ b/src/app/api/radio/shouts/route.ts @@ -0,0 +1,44 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +// Latest 50 radio shouts with their author's username/look resolved. Mirrors the +// query behind the public /radio/shouts page (radio_shouts ordered by created_at +// desc, then joined to users by user_id). +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const shouts = await prisma.radioShouts.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + }); + + // Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt + // while users.id is an Int, so narrow to Number for the lookup. + const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId)))); + const authors = authorIds.length + ? await prisma.user.findMany({ + where: { id: { in: authorIds } }, + select: { id: true, username: true, look: true }, + }) + : []; + const authorById = new Map(authors.map((a) => [a.id, a])); + + const data = shouts.map((s) => { + const author = authorById.get(Number(s.userId)); + return { + id: s.id, + userId: s.userId, + username: author?.username ?? null, + look: author?.look ?? null, + message: s.message, + createdAt: s.createdAt, + }; + }); + + return apiJson({ shouts: data }); + } catch { + // DB unavailable — serve an empty list rather than a 500. + return apiJson({ shouts: [] }, { status: 200 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 00000000..a86863a0 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,34 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +// Public website settings as a flat { key: value } map. Secrets are stripped so +// this can be served to the game client / external integrations. +export const dynamic = "force-dynamic"; + +// Any key containing one of these tokens is considered sensitive and never +// exposed through the public API (mirrors AtomCMS's public settings filtering). +const SENSITIVE = ["secret", "api_key", "password", "token", "webhook"]; + +function isSensitive(key: string): boolean { + const k = key.toLowerCase(); + return SENSITIVE.some((needle) => k.includes(needle)); +} + +export async function GET(_req: Request) { + try { + const rows = await prisma.websiteSetting.findMany({ + select: { key: true, value: true }, + }); + + const settings: Record = {}; + for (const row of rows) { + if (isSensitive(row.key)) continue; + settings[row.key] = row.value; + } + + return apiJson(settings); + } catch { + // DB unavailable — serve an empty settings map rather than a 500. + return apiJson({}, { status: 200 }); + } +} diff --git a/src/app/api/shop/categories/route.ts b/src/app/api/shop/categories/route.ts new file mode 100644 index 00000000..f67aad66 --- /dev/null +++ b/src/app/api/shop/categories/route.ts @@ -0,0 +1,26 @@ +// Public REST: website store categories (website_shop_categories). +// AtomCMS JSON API parity — read-only list ordered by `order` then name. +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const data = await prisma.websiteShopCategories.findMany({ + orderBy: [{ order: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + description: true, + icon: true, + order: true, + }, + }); + + return apiJson({ data }); + } catch { + // DB unreachable — never 500; return empty data. + return apiJson({ data: [] }, { status: 200 }); + } +} diff --git a/src/app/api/shop/route.ts b/src/app/api/shop/route.ts new file mode 100644 index 00000000..e280e80a --- /dev/null +++ b/src/app/api/shop/route.ts @@ -0,0 +1,63 @@ +// Public REST: website store packages (website_shop_articles). +// AtomCMS JSON API parity — read-only list of buyable packages, paginated and +// ordered by `position` (then name), matching the admin /admin/shop query. +import { apiJson, pagination } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const sp = new URL(req.url).searchParams; + const { page, perPage, skip, take } = pagination(sp); + + // Optional ?category= filter (website_shop_category_id is a BigInt). + const categoryRaw = sp.get("category"); + let where: { categoryId?: bigint } = {}; + if (categoryRaw && /^\d+$/.test(categoryRaw)) { + try { + where = { categoryId: BigInt(categoryRaw) }; + } catch { + where = {}; + } + } + + try { + const [total, data] = await Promise.all([ + prisma.websiteShopArticles.count({ where }), + prisma.websiteShopArticles.findMany({ + where, + orderBy: [{ position: "asc" }, { name: "asc" }], + skip, + take, + select: { + id: true, + categoryId: true, + name: true, + info: true, + iconUrl: true, + color: true, + costs: true, + giveRank: true, + isGiftable: true, + credits: true, + duckets: true, + diamonds: true, + badges: true, + furniture: true, + position: true, + }, + }), + ]); + + return apiJson({ + data, + meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) }, + }); + } catch { + // DB unreachable — never 500; return an empty, well-formed payload. + return apiJson( + { data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, + { status: 200 }, + ); + } +} diff --git a/src/app/api/staff/route.ts b/src/app/api/staff/route.ts new file mode 100644 index 00000000..fa72be2e --- /dev/null +++ b/src/app/api/staff/route.ts @@ -0,0 +1,25 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +// Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose +// rank is >= min_staff_rank (default 7). Only safe fields are exposed. +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; + + const staff = await prisma.user.findMany({ + where: { rank: { gte: minStaffRank } }, + select: { username: true, look: true, rank: true, motto: true }, + orderBy: [{ rank: "desc" }, { username: "asc" }], + take: 100, + }); + + return apiJson({ data: staff }, { status: 200 }); + } catch { + // Never 500 — serve an empty payload if the DB is unreachable. + return apiJson({ data: [] }, { status: 200 }); + } +} diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts new file mode 100644 index 00000000..e8f19e2d --- /dev/null +++ b/src/app/api/teams/route.ts @@ -0,0 +1,27 @@ +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +// Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx: +// visible ranks (hiddenRank=false) ordered by id. +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const rows = await prisma.websiteTeams.findMany({ + where: { hiddenRank: false }, + select: { + id: true, + rankName: true, + badge: true, + jobDescription: true, + staffColor: true, + }, + orderBy: { id: "asc" }, + }); + + // apiJson serialises BigInt ids → string automatically. + return apiJson({ data: rows }, { status: 200 }); + } catch { + return apiJson({ data: [] }, { status: 200 }); + } +} diff --git a/src/app/api/users/[username]/route.ts b/src/app/api/users/[username]/route.ts new file mode 100644 index 00000000..c9f27b13 --- /dev/null +++ b/src/app/api/users/[username]/route.ts @@ -0,0 +1,50 @@ +// Public REST API — single user profile by username. +// +// AtomCMS exposed read-only profile JSON for the game site / external +// integrations. Mirrors the same safe field set selected by the public profile +// page (src/app/u/[username]/page.tsx). Never exposes password / auth_ticket / +// 2FA secrets / pincode / mail. + +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ username: string }> }, +) { + const { username } = await params; + + try { + const user = await prisma.user.findUnique({ + where: { username }, + select: { + username: true, + look: true, + motto: true, + rank: true, + credits: true, + online: true, + accountCreated: true, + }, + }); + + if (!user) { + return apiJson({ error: "User not found" }, { status: 404 }); + } + + return apiJson({ + username: user.username, + look: user.look, + motto: user.motto, + rank: user.rank, + credits: user.credits, + online: user.online === "1", + accountCreated: user.accountCreated, + }); + } catch { + // DB unreachable — behave as "not found" rather than 500. + return apiJson({ error: "User not found" }, { status: 404 }); + } +} diff --git a/src/app/api/values/categories/route.ts b/src/app/api/values/categories/route.ts new file mode 100644 index 00000000..d4a1ffa9 --- /dev/null +++ b/src/app/api/values/categories/route.ts @@ -0,0 +1,26 @@ +// Public REST: rare value categories (website_rare_value_categories). +// AtomCMS JSON API parity — read-only list ordered by priority then name, +// matching the admin /admin/rare-values query. +import { apiJson } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request) { + try { + const data = await prisma.websiteRareValueCategories.findMany({ + orderBy: [{ priority: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + badge: true, + priority: true, + }, + }); + + return apiJson({ data }); + } catch { + // DB unreachable — never 500; return empty data. + return apiJson({ data: [] }, { status: 200 }); + } +} diff --git a/src/app/api/values/route.ts b/src/app/api/values/route.ts new file mode 100644 index 00000000..7ab1c4c8 --- /dev/null +++ b/src/app/api/values/route.ts @@ -0,0 +1,56 @@ +// Public REST: rare furni trade values (website_rare_values). +// AtomCMS JSON API parity — read-only catalog of rares with their credit / +// currency values. Supports ?category= filter; paginated, ordered by name. +import { apiJson, pagination } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const sp = new URL(req.url).searchParams; + const { page, perPage, skip, take } = pagination(sp); + + // Optional ?category= filter (category_id is a BigInt). + const categoryRaw = sp.get("category"); + let where: { categoryId?: bigint } = {}; + if (categoryRaw && /^\d+$/.test(categoryRaw)) { + try { + where = { categoryId: BigInt(categoryRaw) }; + } catch { + where = {}; + } + } + + try { + const [total, data] = await Promise.all([ + prisma.websiteRareValues.count({ where }), + prisma.websiteRareValues.findMany({ + where, + orderBy: { name: "asc" }, + skip, + take, + select: { + id: true, + categoryId: true, + itemId: true, + name: true, + creditValue: true, + currencyValue: true, + currencyType: true, + furnitureIcon: true, + }, + }), + ]); + + return apiJson({ + data, + meta: { page, perPage, total, lastPage: Math.max(1, Math.ceil(total / perPage)) }, + }); + } catch { + // DB unreachable — never 500; return an empty, well-formed payload. + return apiJson( + { data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, + { status: 200 }, + ); + } +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index f6af5680..0ce553ec 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -1,7 +1,11 @@ +import Script from "next/script"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { register } from "@/actions/register"; import { ContentCard } from "@/components/public/ui"; +import { captchaConfig } from "@/lib/services/captcha"; + +export const dynamic = "force-dynamic"; export default async function RegisterPage({ searchParams, @@ -10,6 +14,8 @@ export default async function RegisterPage({ }) { const t = await getTranslations("pages.register"); const { error } = await searchParams; + const captcha = await captchaConfig(); + const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; return (
@@ -28,10 +34,22 @@ export default async function RegisterPage({ autoComplete="new-password" required /> + {showCaptcha && captcha.provider === "turnstile" ? ( +
+ ) : null} + {showCaptcha && captcha.provider === "recaptcha" ? ( +
+ ) : null} + {showCaptcha && captcha.provider === "turnstile" ? ( +