Add public REST API, anti-abuse protections, radio/GitHub cron jobs
Phase A — Public REST API (was the biggest gap). 20 JSON endpoints under
/api mirroring AtomCMS: users/[username], online(+/count), me, articles
(+/[slug]), photos, home, staff, teams, leaderboard, shop(+/categories),
values(+/categories), settings, radio/{config,now-playing,listeners,
shouts}. Shared src/lib/api.ts (apiJson — BigInt-safe + CORS, pagination).
Read-only, fail-soft, and field-safe (never exposes password/auth_ticket/
2FA secrets/mail).
Phase B — Anti-abuse on registration: CAPTCHA (Cloudflare Turnstile /
Google reCAPTCHA, settings-driven, widget rendered on the register page),
VPN/proxy detection (proxycheck.io / IPQualityScore via /admin/vpn
settings), and max-accounts-per-IP. All fail-open when unconfigured.
src/lib/services/{captcha,ip-lookup}.ts.
Phase C — jobs-worker cron suite: radio-record-songs (30s, logs track
changes to radio_song_plays), radio-auto-dj (rotates radio_auto_dj_playlist
when no live DJ), github-update-check (hourly, sets update_available).
Shared src/lib/services/radio.ts (now-playing/listeners parsing).
Verified live (prod, amx_test): /api/* return real JSON (leaderboard 6
users, settings carry no secrets, user endpoint hides password). tsc 0,
vitest 49/49, next build 0 (20 new API routes).
This commit is contained in:
1 parent
5a4b6f27e9
commit
80f591a343
29 files changed
+1697
-7
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<Exclude<LeaderboardType, "credits">, number> = {
|
||||
diamonds: 5,
|
||||
duckets: 0,
|
||||
};
|
||||
|
||||
type Row = { rank: number; username: string; look: string; value: number };
|
||||
|
||||
async function loadCreditsRows(): Promise<Row[]> {
|
||||
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<Row[]> {
|
||||
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<Row, "rank"> => 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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=<id> 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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=<id> 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=<id> 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user