Fill the remaining gaps: badge upload, radio tools, VPN, writeable boxes
Built the admin tools previously listed as missing: - Badge upload (/admin/badges): uploads a <code>.gif into the emulator's badge dir via BADGE_UPLOAD_DIR (node:fs); validated code/type/size, logged. Made configurable rather than skipped. - Radio tools: /admin/radio/api-keys (CRUD, server-generated keys), /admin/radio/autodj (Auto-DJ playlist CRUD), /admin/radio/embed (embed snippet generator), /admin/radio/points (points settings), /admin/radio/monitoring (live stream/now-playing/listeners status). radio_api_keys + radio_auto_dj_playlist already had real columns. - /admin/vpn: VPN/proxy detection config (block toggle + provider + key), complementing /admin/ip's raw blacklist. - Writeable boxes: new website_writeable_boxes table (model + migration 0006) + /admin/writeable-boxes CRUD; active boxes render on the public home page. env: BADGE_UPLOAD_DIR. Verified live (prod, amx_test): all 8 pages render with real data; a test writeable box appeared on the public home and was reverted. tsc 0, vitest 49/49, next build 0 (7 new admin routes).
This commit is contained in:
1 parent
e004dfedaf
commit
0cd4d06ff6
21 files changed
+2443
-61
No files matched your search
@@ -22,6 +22,11 @@ CONVERT_PASSWORDS=false
|
|||||||
# column). Existing accounts in either format still verify on login.
|
# column). Existing accounts in either format still verify on login.
|
||||||
PASSWORD_HASH=bcrypt
|
PASSWORD_HASH=bcrypt
|
||||||
|
|
||||||
|
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
||||||
|
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
||||||
|
# Leave unset to disable badge uploads.
|
||||||
|
BADGE_UPLOAD_DIR=
|
||||||
|
|
||||||
# RCON link to the Arcturus emulator
|
# RCON link to the Arcturus emulator
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3001
|
RCON_PORT=3001
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CMS-owned editable content boxes (AtomCMS WriteableBox). Rendered on the
|
||||||
|
-- public site and managed from housekeeping. Idempotent (MariaDB).
|
||||||
|
CREATE TABLE IF NOT EXISTS website_writeable_boxes (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
icon VARCHAR(255) NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -1674,6 +1674,19 @@ model WebsiteWordfilter {
|
|||||||
@@map("website_wordfilter")
|
@@map("website_wordfilter")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WebsiteWriteableBoxes {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
icon String? @db.VarChar(255)
|
||||||
|
content String @db.Text
|
||||||
|
position Int @default(0)
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime? @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@map("website_writeable_boxes")
|
||||||
|
}
|
||||||
|
|
||||||
model WebsiteBetaCodes {
|
model WebsiteBetaCodes {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(255)
|
code String @unique @db.VarChar(255)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// Writes a badge image to the configured emulator badge directory. The path is
|
||||||
|
// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's
|
||||||
|
// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only
|
||||||
|
// ever stores .gif badges, so every upload is normalised to `<code>.gif`.
|
||||||
|
|
||||||
|
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
const MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
|
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
|
||||||
|
|
||||||
|
function back(param: string, value: string): never {
|
||||||
|
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
|
||||||
|
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||||
|
if (!dir) {
|
||||||
|
back("error", "Badge upload directory not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(formData.get("code") ?? "").trim();
|
||||||
|
if (!CODE_RE.test(code)) {
|
||||||
|
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
back("error", "No file uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size === 0) {
|
||||||
|
back("error", "Uploaded file is empty");
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
back("error", "File too large (max 1MB)");
|
||||||
|
}
|
||||||
|
if (!ALLOWED_TYPES.has(file.type)) {
|
||||||
|
back("error", "File must be a GIF or PNG image");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const target = path.join(dir, `${code}.gif`);
|
||||||
|
await writeFile(target, buffer);
|
||||||
|
} catch {
|
||||||
|
back("error", "Could not write the badge file to disk");
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "badge_upload",
|
||||||
|
description: `Uploaded badge image "${code}.gif"`,
|
||||||
|
targetType: "badge",
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||||
|
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||||
|
// website_settings that reward listeners for time spent on the radio. Booleans
|
||||||
|
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
|
||||||
|
// pages pick the change up immediately.
|
||||||
|
|
||||||
|
const POINTS_KEYS = [
|
||||||
|
"radio_points_enabled",
|
||||||
|
"radio_points_per_minute",
|
||||||
|
"radio_points_currency",
|
||||||
|
"radio_points_max_per_day",
|
||||||
|
"radio_points_min_listeners",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]);
|
||||||
|
|
||||||
|
function str(raw: FormDataEntryValue | null): string {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checkbox/select truthiness → '1' / '0'. */
|
||||||
|
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
|
||||||
|
const v = str(raw).trim().toLowerCase();
|
||||||
|
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
|
||||||
|
function intStr(raw: FormDataEntryValue | null): string {
|
||||||
|
const n = Number(str(raw).trim());
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "0";
|
||||||
|
return String(Math.floor(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePoints(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
|
||||||
|
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase();
|
||||||
|
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits";
|
||||||
|
|
||||||
|
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||||
|
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||||
|
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||||
|
radio_points_currency: currency,
|
||||||
|
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||||
|
radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
POINTS_KEYS.map((key) =>
|
||||||
|
prisma.websiteSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: values[key] },
|
||||||
|
create: { key, value: values[key], comment: "Radio points" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "radio_points_update",
|
||||||
|
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — fail soft so the action does not throw.
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/radio/points");
|
||||||
|
redirect("/admin/radio/points?saved=1");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
// VPN / proxy detection config. Stored as website_settings key/value rows
|
||||||
|
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
|
||||||
|
// AtomCMS's setting() convention. This is registration-time protection only;
|
||||||
|
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
|
||||||
|
|
||||||
|
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||||
|
|
||||||
|
/** Upsert one website_settings key with a stable housekeeping comment. */
|
||||||
|
async function writeSetting(key: string, value: string, comment: string): Promise<void> {
|
||||||
|
await prisma.websiteSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value },
|
||||||
|
create: { key, value, comment },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVpn(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requireStaff();
|
||||||
|
|
||||||
|
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||||
|
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
|
||||||
|
|
||||||
|
const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase();
|
||||||
|
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||||
|
|
||||||
|
const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255);
|
||||||
|
const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_block_enabled",
|
||||||
|
enabled ? "1" : "0",
|
||||||
|
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||||
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_provider",
|
||||||
|
provider,
|
||||||
|
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||||
|
);
|
||||||
|
await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider");
|
||||||
|
await writeSetting(
|
||||||
|
"vpn_block_message",
|
||||||
|
blockMessage,
|
||||||
|
"Message shown to users blocked for using a VPN/proxy",
|
||||||
|
);
|
||||||
|
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "vpn_update",
|
||||||
|
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/vpn");
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — fail soft so the action does not throw; the page
|
||||||
|
// re-renders the current (stored) state.
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/admin/vpn?saved=1");
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+128
-61
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[]
|
|||||||
{ href: "/admin/navigation", label: "Navigator" },
|
{ href: "/admin/navigation", label: "Navigator" },
|
||||||
{ href: "/admin/help-questions", label: "Help center" },
|
{ href: "/admin/help-questions", label: "Help center" },
|
||||||
{ href: "/admin/ads", label: "Advertisements" },
|
{ href: "/admin/ads", label: "Advertisements" },
|
||||||
|
{ href: "/admin/writeable-boxes", label: "Writeable boxes" },
|
||||||
{ href: "/admin/photos", label: "Photos" },
|
{ href: "/admin/photos", label: "Photos" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[]
|
|||||||
{ href: "/admin/users", label: "Users" },
|
{ href: "/admin/users", label: "Users" },
|
||||||
{ href: "/admin/bans", label: "Bans" },
|
{ href: "/admin/bans", label: "Bans" },
|
||||||
{ href: "/admin/ip", label: "IP management" },
|
{ href: "/admin/ip", label: "IP management" },
|
||||||
|
{ href: "/admin/vpn", label: "VPN" },
|
||||||
{ href: "/admin/applications", label: "Applications" },
|
{ href: "/admin/applications", label: "Applications" },
|
||||||
{ href: "/admin/teams", label: "Teams" },
|
{ href: "/admin/teams", label: "Teams" },
|
||||||
{ href: "/admin/permissions", label: "Permissions" },
|
{ href: "/admin/permissions", label: "Permissions" },
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only copy box for an embed snippet. Renders the code inside a
|
||||||
|
* <textarea readOnly> so staff can select/copy it, plus a one-click copy
|
||||||
|
* button (with clipboard fallback to selecting the text).
|
||||||
|
*/
|
||||||
|
export function EmbedSnippet({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
snippet,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
snippet: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(snippet);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API unavailable (e.g. insecure context) — fall back to
|
||||||
|
// selecting the textarea so the user can copy manually.
|
||||||
|
const el = document.getElementById(
|
||||||
|
`snippet-${title.replace(/\s+/g, '-').toLowerCase()}`,
|
||||||
|
) as HTMLTextAreaElement | null;
|
||||||
|
el?.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `snippet-${title.replace(/\s+/g, '-').toLowerCase()}`;
|
||||||
|
const lines = snippet.split('\n').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<h2 style={{ flex: 1, margin: 0 }}>{title}</h2>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={copy}>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{description ? (
|
||||||
|
<p className="muted" style={{ marginTop: '0.4rem' }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
readOnly
|
||||||
|
value={snippet}
|
||||||
|
rows={Math.min(Math.max(lines + 1, 3), 14)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
spellCheck={false}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,15 @@ export default async function HomePage() {
|
|||||||
// DB unavailable — show the shell without articles.
|
// DB unavailable — show the shell without articles.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staff-authored content boxes (managed at /admin/writeable-boxes).
|
||||||
|
const boxes = await prisma.websiteWriteableBoxes
|
||||||
|
.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||||
|
select: { id: true, title: true, icon: true, content: true },
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
<ContentCard
|
<ContentCard
|
||||||
@@ -81,6 +90,14 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
|
{boxes.map((b) => (
|
||||||
|
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
|
||||||
|
{/* Content is authored by staff in housekeeping (trusted HTML). */}
|
||||||
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored writeable box */}
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: b.content }} />
|
||||||
|
</ContentCard>
|
||||||
|
))}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,10 @@ const schema = z.object({
|
|||||||
.transform((v) => v === "true" || v === "1"),
|
.transform((v) => v === "true" || v === "1"),
|
||||||
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
|
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
|
||||||
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
|
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
|
||||||
|
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||||
|
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||||
|
// when unset.
|
||||||
|
BADGE_UPLOAD_DIR: z.string().optional(),
|
||||||
// Optional AI content moderation (comments / guestbook).
|
// Optional AI content moderation (comments / guestbook).
|
||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
// Optional alerting (jobs worker / alert service).
|
// Optional alerting (jobs worker / alert service).
|
||||||
|
|||||||
Reference in new issue
Block a user