feat: custom modifications

This commit is contained in:
dvelo 2025-04-04 21:10:50 -05:00
parent bddf5f1528
commit ed2d6f0ac1
21 changed files with 1686 additions and 875 deletions

@ -1,5 +1,5 @@
{ {
"name": "mh-stats", "name": "mhsf",
"version": "1.3.0", "version": "1.3.0",
"private": true, "private": true,
"packageManager": "yarn@1.22.22", "packageManager": "yarn@1.22.22",
@ -35,9 +35,15 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "1.1.0", "@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/next": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@types/request-ip": "^0.0.41",
"@unocss/eslint-plugin": "^0.61.5", "@unocss/eslint-plugin": "^0.61.5",
"@unocss/postcss": "^0.61.5", "@unocss/postcss": "^0.61.5",
"@unocss/transformer-directives": "^0.61.5", "@unocss/transformer-directives": "^0.61.5",
@ -80,8 +86,10 @@
"recharts": "^2.15.1", "recharts": "^2.15.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"request-ip": "^3.3.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",
"stripe-gradient": "^1.0.1", "stripe-gradient": "^1.0.1",
"superjson": "^2.2.2",
"swapy": "^1.0.5", "swapy": "^1.0.5",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
@ -89,7 +97,8 @@
"tailwindcss-patch": "^4.0.0", "tailwindcss-patch": "^4.0.0",
"turbo": "^2.4.0", "turbo": "^2.4.0",
"unplugin-tailwindcss-mangle": "^3.0.1", "unplugin-tailwindcss-mangle": "^3.0.1",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@clerk/themes": "^2.1.19", "@clerk/themes": "^2.1.19",

@ -0,0 +1,122 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
"use client";
import { ModificationAction } from "@/components/feat/server-list/modification/modification-action";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "@/components/feat/settings/setting";
import { Button } from "@/components/ui/button";
import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db";
import { useUser } from "@clerk/nextjs";
import { ArrowLeft, Filter, SortAsc } from "lucide-react";
import { useQueryState } from "nuqs";
import { use } from "react";
import Markdown from "react-markdown";
export default function ModificationPage({
params,
}: {
params: Promise<{ category: string; "custom-mod": string }>;
}) {
const { category, "custom-mod": mod } = use(params);
const { user } = useUser();
const [backRoute] = useQueryState("b", {
defaultValue: "/servers/embedded/sl-modification-frame",
});
console.log(mod);
const modObj = (
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ?? []
).find((c) => c.friendlyName === atob(decodeURIComponent(mod)));
if (modObj === undefined)
return <>We couldn't find the modification you were looking for.</>;
return (
<main className="max-w-[800px] p-4">
<div
className="h-[150px] w-full rounded-xl p-2"
style={{ backgroundColor: modObj?.color }}
>
<Link href={backRoute}>
<ArrowLeft />
</Link>
</div>
<span className="p-4">
<h1 className="text-xl font-bold w-full">{modObj?.friendlyName}</h1>
<Markdown className="text-wrap pt-2">
This is a custom modification. Enable it! (or not) It's your own! (are
you proud?)
</Markdown>
<Button className="mt-2">
{modObj?.active ? "Disable" : "Enable"}
</Button>
<Separator className="mt-3" />
<Material className="mt-6">
<Setting>
<SettingContent className="flex items-center">
<SettingMeta>
<SettingTitle>Type</SettingTitle>
<SettingDescription>
What type of modification is this?
</SettingDescription>
</SettingMeta>
<div className="flex items-center">
{modObj.testMode === "sort" ? (
<div className="flex items-center gap-1">
<SortAsc size={16} />
Sort
</div>
) : (
<div className="flex items-center gap-1">
<Filter size={16} /> Filter
</div>
)}
</div>
</SettingContent>
</Setting>
</Material>
</span>
</main>
);
}

@ -31,12 +31,15 @@
"use client"; "use client";
import { ModificationAction } from "@/components/feat/server-list/modification/modification-action"; import { ModificationAction } from "@/components/feat/server-list/modification/modification-action";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import { Material } from "@/components/ui/material"; import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link"; import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db"; import { serverModDB } from "@/config/sl-mod-db";
import { useRouter } from "@/lib/useRouter"; import { useRouter } from "@/lib/useRouter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArrowLeft } from "lucide-react"; import { SignedIn, useUser } from "@clerk/nextjs";
import { ArrowLeft, Binary } from "lucide-react";
import { use } from "react"; import { use } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
@ -45,9 +48,10 @@ export default function ServerListCategoryFrame({
}: { }: {
params: Promise<{ category: string }>; params: Promise<{ category: string }>;
}) { }) {
const { user } = useUser();
const { category } = use(params); const { category } = use(params);
const categoryObj = serverModDB.find( const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(category) (c) => c.displayTitle === atob(category),
); );
const router = useRouter(); const router = useRouter();
@ -68,14 +72,14 @@ export default function ServerListCategoryFrame({
elevation="high" elevation="high"
onClick={() => onClick={() =>
router.push( router.push(
`/servers/embedded/sl-modification-frame/category/${category}/modification/${btoa(m.name)}?b=${encodeURIComponent(`/servers/embedded/sl-modification-frame/category/${category}`)}` `/servers/embedded/sl-modification-frame/category/${category}/modification/${btoa(m.name)}?b=${encodeURIComponent(`/servers/embedded/sl-modification-frame/category/${category}`)}`,
) )
} }
key={m.name} key={m.name}
> >
<div <div
className={cn( className={cn(
"w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center" "w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center",
)} )}
style={{ backgroundColor: m.color }} style={{ backgroundColor: m.color }}
> >
@ -86,6 +90,35 @@ export default function ServerListCategoryFrame({
</span> </span>
</Material> </Material>
))} ))}
<SignedIn>
{categoryObj?.__custom &&
(
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[]
).map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.friendlyName}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${category}/modification/_custom/${btoa(m.friendlyName)}`,
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<Binary className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.friendlyName}
</span>
</Material>
))}
</SignedIn>
</Material> </Material>
</main> </main>
); );

@ -2,9 +2,12 @@
import { use, useEffect, useRef, useState } from "react"; import { use, useEffect, useRef, useState } from "react";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
import type { ClerkCustomModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog"; import type {
ClerkCustomActivatedModification,
ClerkCustomModification,
} from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import { Link } from "@/components/util/link"; import { Link } from "@/components/util/link";
import { AlertOctagon, ArrowLeft, ExternalLink } from "lucide-react"; import { AlertOctagon, ArrowLeft, Check, ExternalLink } from "lucide-react";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@ -35,6 +38,31 @@ import { Geist_Mono } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { tryCatch } from "@/lib/try-catch"; import { tryCatch } from "@/lib/try-catch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Material } from "@/components/ui/material";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "@/components/feat/settings/setting";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { DialogTrigger } from "@/components/ui/dialog";
import { useRouter } from "@/lib/useRouter";
const typeDefs = `// Hi :) how'd you get here? const typeDefs = `// Hi :) how'd you get here?
// Here, in return I'll provide you with a random number: ${Math.ceil(Math.random() * 100)} // Here, in return I'll provide you with a random number: ${Math.ceil(Math.random() * 100)}
@ -110,6 +138,8 @@ export default function CustomFilePage({
const [syntaxErrors, setSyntaxErrors] = useState< const [syntaxErrors, setSyntaxErrors] = useState<
languages.typescript.Diagnostic[] | null languages.typescript.Diagnostic[] | null
>(null); >(null);
const [testMode, setTestMode] = useState("");
const router = useRouter();
const file = ( const file = (
(user?.unsafeMetadata.customFiles as Array<ClerkCustomModification>) ?? [] (user?.unsafeMetadata.customFiles as Array<ClerkCustomModification>) ?? []
).findIndex((c) => c.name === filename); ).findIndex((c) => c.name === filename);
@ -125,13 +155,13 @@ export default function CustomFilePage({
.getTypeScriptWorker() .getTypeScriptWorker()
.then((worker) => { .then((worker) => {
worker( worker(
monacoRef.current?.Uri.parse(`file:///${filename}.ts`) as Uri monacoRef.current?.Uri.parse(`file:///${filename}.ts`) as Uri,
).then((client) => { ).then((client) => {
client client
.getSemanticDiagnostics( .getSemanticDiagnostics(
( (
monacoRef.current?.Uri.parse(`file:///${filename}.ts`) as Uri monacoRef.current?.Uri.parse(`file:///${filename}.ts`) as Uri
).toString() ).toString(),
) )
.then((diags) => { .then((diags) => {
setSyntaxErrors(diags); setSyntaxErrors(diags);
@ -158,6 +188,7 @@ export default function CustomFilePage({
await user?.update({ await user?.update({
unsafeMetadata: { unsafeMetadata: {
...user.unsafeMetadata,
customFiles: metadata, customFiles: metadata,
}, },
}); });
@ -166,7 +197,7 @@ export default function CustomFilePage({
const lintFile = async () => { const lintFile = async () => {
toast.info("Transpiling TypeScript..."); toast.info("Transpiling TypeScript...");
const { error, data: transpiledCode } = await tryCatch( const { error, data: transpiledCode } = await tryCatch(
(async () => transpileTypeScript(value))() (async () => transpileTypeScript(value))(),
); );
if (error) { if (error) {
toast.error("Failed to transpile TypeScript! Error: " + error.message); toast.error("Failed to transpile TypeScript! Error: " + error.message);
@ -177,13 +208,12 @@ export default function CustomFilePage({
toast.error("Cannot continue."); toast.error("Cannot continue.");
return; return;
} }
console.log("[MHSF Filters] Transpiled TypeScript:", transpiledCode ?? "");
toast.info("Generating function..."); toast.info("Generating function...");
const functionBody = transpiledCode.match( const functionBody = transpiledCode.match(
/function\s+filter\s*\([^)]*\)\s*\{([\s\S]*)\}/ /function\s+filter\s*\([^)]*\)\s*\{([\s\S]*)\}/,
)?.[1]; )?.[1];
const { error: filterErr, data: filterFunc } = await tryCatch( const { error: filterErr, data: filterFunc } = await tryCatch(
(async () => new Function("data", functionBody as string))() (async () => new Function("data", functionBody as string))(),
); );
if (filterErr) { if (filterErr) {
toast.error(`Failed to generate function! Error: ${filterErr.message}`); toast.error(`Failed to generate function! Error: ${filterErr.message}`);
@ -202,7 +232,7 @@ export default function CustomFilePage({
const { error } = await tryCatch(saveFile()); const { error } = await tryCatch(saveFile());
if (error) if (error)
toast.error( toast.error(
"Whoa! We encountered an error while auto-saving. Please copy your code locally to ensure you'll keep your code changes." "Whoa! We encountered an error while auto-saving. Please copy your code locally to ensure you'll keep your code changes.",
); );
}, 300); }, 300);
@ -301,25 +331,272 @@ export default function CustomFilePage({
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
{(() => {
const [open, setOpen] = useState(false);
const [filterEnabled, setFilterEnabled] = useState(true);
const [sortEnabled, setSortEnabled] = useState(true);
const [success, setSuccess] = useState(false);
const [fileName, setFileName] = useState("");
useEffect(() => {
setFilterEnabled(true);
setSortEnabled(true);
setTestMode("");
(async () => {
const transpiledValue = transpileTypeScript(value);
const functionBody = transpiledValue
?.replace(/export default(?!.*[;])/g, "") // Avoid replacing if followed by a semicolon
.replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon
const { error: filterErr, data: filterFunc } =
await tryCatch(
(async () =>
new Function(
"server",
`${functionBody}
return filter(server)`,
))(),
);
const { error: sortErr, data: sortFunc } = await tryCatch(
(async () =>
new Function(
"serverA",
"serverB",
`${functionBody}
return sort(serverA, serverB)`,
))(),
);
if (filterErr) setFilterEnabled(false);
if (sortErr) setSortEnabled(false);
try {
filterFunc?.({});
} catch (e) {
if (
String(e).startsWith(
"ReferenceError: filter is not defined",
)
) {
setFilterEnabled(false);
}
}
try {
sortFunc?.({}, {});
} catch (e) {
if (
String(e).startsWith(
"ReferenceError: sort is not defined",
)
) {
setSortEnabled(false);
}
}
})();
}, [open]);
return (
<>
<Button <Button
disabled={!successfullyLinted} disabled={!successfullyLinted}
onClick={() => { onClick={() => setOpen(true)}
const t = btoa(value);
const newTab = window.open(`/servers?tm=${encodeURIComponent(t)}`)
const interval = setInterval(() => {
newTab?.dispatchEvent(new Event("test-mode.enable"))
}, 500)
toast.info("Waiting for server tab to pick up thread...")
newTab?.addEventListener("test-mode.enabled", () => {
clearInterval(interval);
toast.success("Connected to new tab; continue.")
})
}}
> >
Test Test
</Button> </Button>
<Drawer
direction="right"
open={open}
onOpenChange={setOpen}
>
<DrawerContent className="p-4 min-w-[400px] overflow-x-hidden max-h-screen overflow-y-auto">
<p className="text-sm mb-2">
You can run an interactive server-list environment
with actual online servers to test your modifications.
</p>
<Material>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Function to test</SettingTitle>
<SettingDescription>
You can pick to either test a sorting system
or a filter.
</SettingDescription>
</SettingMeta>
<Select
value={testMode}
onValueChange={setTestMode}
disabled={success}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
value="filter"
disabled={!filterEnabled}
>
<code>filter</code>
</SelectItem>
<SelectItem
value="sort"
disabled={!sortEnabled}
>
<code>sort</code>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</SettingContent>
</Setting>
</Material>
<Button
className="w-full mt-2 flex items-center gap-2"
disabled={testMode === "" || success}
variant={success ? "success-subtle" : "default"}
onClick={() => {
const t = btoa(value);
const newTab = window.open(
`/servers?tm=${encodeURIComponent(t)}`,
);
const interval = setInterval(() => {
newTab?.dispatchEvent(
new Event("test-mode.enable." + testMode),
);
}, 500);
toast.info(
"Waiting for server tab to pick up thread...",
);
newTab?.addEventListener(
"test-mode.enabled",
() => {
clearInterval(interval);
toast.success(
"Connected to new tab; continue.",
);
newTab?.addEventListener(
"test-mode.success",
() => {
toast.success(
"Resolved success from thread!",
);
setSuccess(true);
},
);
},
);
}}
>
{success && <Check size={16} />}Test
</Button>
{success && (
<>
<p className="text-sm my-2">
You can now activate this custom modification.
Please note that the filter and sort versions of
your modifications will be different, and the one
used will be selected based on what type you
tested on.
</p>
{(
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[]
).find((c) => c.originalFileName === filename && c.testMode === testMode) !==
undefined && (
<Alert className="mb-2 gap-2" variant="warning">
This modification was already activated! Hitting
activate here will just overwrite the contents
and the new friendly name.
</Alert>
)}
<Material>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Name</SettingTitle>
<SettingDescription>
Set a friendly name for your modification.
</SettingDescription>
</SettingMeta>
<Input
placeholder="My cool mod"
value={fileName}
onChange={(c) =>
setFileName(c.target.value)
}
/>
</SettingContent>
</Setting>
</Material>
<DialogTrigger>
<Button
className="w-full my-2"
disabled={fileName === ""}
onClick={async () => {
const array =
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[];
const index = array.findIndex(
(c) => c.originalFileName === filename && c.testMode === testMode,
);
const color = '#' + Math.floor(Math.random() * 16777215).toString(16);
const transpiledValue =
transpileTypeScript(value);
if (transpiledValue === null)
return toast.error("Error transpiling");
if (index !== -1) {
// Original already exists
array[index] = {
originalFileName: filename,
// I'm too lazy to change this
friendlyName: fileName,
transpiledContents: transpiledValue,
active: true,
testMode: testMode as "filter" | "sort",
color
};
} else {
array.push({
originalFileName: filename,
// ... and this too
friendlyName: fileName,
transpiledContents: transpiledValue,
active: true,
testMode: testMode as "filter" | "sort",
color
});
}
await user?.update({
unsafeMetadata: {
...user.unsafeMetadata,
activatedModifications: array,
},
});
toast.success("Activated!")
router.push("/servers/embedded/sl-modification-frame")
}}
>
Activate
</Button>
</DialogTrigger>
</>
)}
</DrawerContent>
</Drawer>
</>
);
})()}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{successfullyLinted {successfullyLinted
@ -361,7 +638,7 @@ export default function CustomFilePage({
// Add typedefs as a library // Add typedefs as a library
monaco.languages.typescript.typescriptDefaults.addExtraLib( monaco.languages.typescript.typescriptDefaults.addExtraLib(
typeDefs, typeDefs,
libUri libUri,
); );
// Add actions // Add actions
@ -377,7 +654,7 @@ export default function CustomFilePage({
{ {
id: "lint-file", id: "lint-file",
label: "MHSF: Lint File", label: "MHSF: Lint File",
run: lintFile run: lintFile,
}, },
].forEach((e) => editor.addAction(e)); ].forEach((e) => editor.addAction(e));
@ -386,7 +663,7 @@ export default function CustomFilePage({
monaco.editor.createModel( monaco.editor.createModel(
typeDefs, typeDefs,
"typescript", "typescript",
monaco.Uri.parse(libUri) monaco.Uri.parse(libUri),
); );
} }
@ -433,6 +710,53 @@ export default function CustomFilePage({
); );
} }
export async function findSupportedOperations(
fileValue: string,
): Promise<{ filter: boolean; sort: boolean }> {
const returnValue = { filter: true, sort: true };
const transpiledValue = transpileTypeScript(fileValue);
const functionBody = transpiledValue
?.replace(/export default(?!.*[;])/g, "") // Avoid replacing if followed by a semicolon
.replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon
const { error: filterErr, data: filterFunc } = await tryCatch(
(async () =>
new Function(
"server",
`${functionBody}
return filter(server)`,
))(),
);
const { error: sortErr, data: sortFunc } = await tryCatch(
(async () =>
new Function(
"serverA",
"serverB",
`${functionBody}
return sort(serverA, serverB)`,
))(),
);
if (filterErr) returnValue.filter = false;
if (sortErr) returnValue.sort = false;
try {
filterFunc?.({});
} catch (e) {
if (String(e).startsWith("ReferenceError: filter is not defined")) {
returnValue.filter = false;
}
}
try {
sortFunc?.({}, {});
} catch (e) {
if (String(e).startsWith("ReferenceError: sort is not defined")) {
returnValue.sort = false;
}
}
return returnValue;
}
function guidGenerator() { function guidGenerator() {
const S4 = () => { const S4 = () => {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);

@ -47,15 +47,21 @@ import {
Braces, Braces,
EllipsisVertical, EllipsisVertical,
FileCode, FileCode,
Filter,
Pencil, Pencil,
SortAsc,
Trash, Trash,
} from "lucide-react"; } from "lucide-react";
import { use } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { findSupportedOperations } from "../file/[filename]/page";
export default function ServerListModificationFrame() { export default function ServerListModificationFrame() {
const { user } = useUser(); const { user } = useUser();
const files = const files =
(user?.unsafeMetadata.customFiles as Array<ClerkCustomModification>) ?? []; (user?.unsafeMetadata.customFiles as Array<ClerkCustomModification>) ?? [];
const operations = use((async () => await Promise.all(files.map(async (c) => await findSupportedOperations(c.contents))))())
console.log(operations)
return ( return (
<main className="max-w-[800px] p-4"> <main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full flex items-center gap-2"> <h1 className="text-xl font-bold w-full flex items-center gap-2">
@ -80,6 +86,8 @@ export default function ServerListModificationFrame() {
> >
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<FileCode size={16} /> <FileCode size={16} />
{operations[i].filter && <Filter size={16}/>}
{operations[i].sort && <SortAsc size={16}/>}
{c.name}.ts {c.name}.ts
</span> </span>
<span> <span>
@ -105,6 +113,7 @@ export default function ServerListModificationFrame() {
files.splice(i, 1); files.splice(i, 1);
await user?.update({ await user?.update({
unsafeMetadata: { unsafeMetadata: {
...user.unsafeMetadata,
customFiles: files, customFiles: files,
}, },
}); });

@ -34,12 +34,15 @@ import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link"; import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db"; import { serverModDB } from "@/config/sl-mod-db";
import { ArrowRight } from "lucide-react"; import { ArrowRight, Binary } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useRouter } from "@/lib/useRouter"; import { useRouter } from "@/lib/useRouter";
import { SignedIn, useUser } from "@clerk/nextjs";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
export default function ServerListModificationFrame() { export default function ServerListModificationFrame() {
const router = useRouter(); const router = useRouter();
const { user } = useUser();
return ( return (
<main className="max-w-[800px] p-4"> <main className="max-w-[800px] p-4">
@ -58,7 +61,7 @@ export default function ServerListModificationFrame() {
</span> </span>
<Material className="mt-10 p-4"> <Material className="mt-10 p-4">
{serverModDB.map((c) => ( {serverModDB.map((c) => (
<span key={c.displayTitle}> <div key={c.displayTitle} className="my-4">
<h2 className="text-lg font-bold pb-3 flex justify-between"> <h2 className="text-lg font-bold pb-3 flex justify-between">
{c.displayTitle} {c.displayTitle}
<Link <Link
@ -77,7 +80,7 @@ export default function ServerListModificationFrame() {
key={m.name} key={m.name}
onClick={() => onClick={() =>
router.push( router.push(
`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/${btoa(m.name)}` `/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/${btoa(m.name)}`,
) )
} }
> >
@ -92,8 +95,37 @@ export default function ServerListModificationFrame() {
</span> </span>
</Material> </Material>
))} ))}
<SignedIn>
{c.__custom &&
(
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[]
).map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.friendlyName}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/custom/${btoa(m.friendlyName)}`,
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<Binary className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div> </div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.friendlyName}
</span> </span>
</Material>
))}
</SignedIn>
</div>
</div>
))} ))}
</Material> </Material>
</main> </main>

@ -0,0 +1,49 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { createContext } from "@/server/context";
import { appRouter } from "@/server/router/_app";
import { getAuth } from "@clerk/nextjs/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
// I still have no clue why this works.
import type { NextRequest } from "../../../../../../../node_modules/@clerk/nextjs/node_modules/next/dist/server/web/spec-extension/request";
const handler = (request: NextRequest) => {
const {userId} = getAuth(request);
return fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: appRouter,
createContext: createContext(userId),
});
};
export { handler as GET, handler as POST };

@ -236,7 +236,7 @@
.loading-shimmer { .loading-shimmer {
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
animation-duration: 2.5s; animation-duration: 2s;
animation-iteration-count: infinite; animation-iteration-count: infinite;
animation-name: loading-shimmer; animation-name: loading-shimmer;
background: var(--color-muted-foreground) background: var(--color-muted-foreground)

@ -1,6 +1,7 @@
import { BrandingGenericIcon } from "../icons/branding-icons"; import { BrandingGenericIcon } from "../icons/branding-icons";
import { Link } from "../../util/link"; import { Link } from "../../util/link";
import { FooterStatus } from "./status"; import { FooterStatus } from "./status";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export function Footer() { export function Footer() {
return ( return (
@ -35,6 +36,29 @@ export function Footer() {
Contact Contact
</Link> </Link>
</li> </li>
<li className="text-sm">
<DropdownMenu
>
<DropdownMenuTrigger>
<a className="text-muted-foreground hover:text-shadcn-primary transition-colors cursor-pointer">Discord</a>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href="https://t.mhsf.app/d/m" noExtraIcons>
<DropdownMenuItem className="block py-2">
Minehut Discord
<small className="flex">Not officially owned by MHSF, however conversations about MHSF and related take place there.</small>
</DropdownMenuItem>
</Link>
<Link href="https://t.mhsf.app/d/u" noExtraIcons>
<DropdownMenuItem className="block py-2">
MHSF Discord
<small className="flex">A read-only server for updates related to MHSF.</small>
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
</li>
</ul> </ul>
</span> </span>
<FooterStatus /> <FooterStatus />

@ -63,6 +63,15 @@ export type ClerkCustomModification = {
testId?: string; testId?: string;
}; };
export type ClerkCustomActivatedModification = {
originalFileName: string;
friendlyName: string;
transpiledContents: string;
active: boolean;
testMode: "filter" | "sort";
color: string;
}
export function ModificationFileCreationDialog({ export function ModificationFileCreationDialog({
children, children,
type, type,
@ -98,10 +107,11 @@ export function ModificationFileCreationDialog({
<DialogTrigger> <DialogTrigger>
<Button <Button
className="w-full" className="w-full"
onClick={(e) => { onClick={async (e) => {
if (!isSignedIn) return toast.error("Please login."); if (!isSignedIn) return toast.error("Please login.");
user?.update({ await user?.update({
unsafeMetadata: { unsafeMetadata: {
...user.unsafeMetadata,
customFiles: [ customFiles: [
...((user.unsafeMetadata ...((user.unsafeMetadata
.customFiles as Array<ClerkCustomModification>) ?? []), .customFiles as Array<ClerkCustomModification>) ?? []),

@ -1,5 +1,10 @@
import { AnimatedText } from "@/components/ui/animated-text"; import { AnimatedText } from "@/components/ui/animated-text";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
export function ServerTestModeSelector({ export function ServerTestModeSelector({
@ -19,15 +24,26 @@ export function ServerTestModeSelector({
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<span className="relative flex size-2.5 pt-[1px] items-center cursor-pointer"> <span className="relative flex size-2.5 pt-[1px] items-center cursor-pointer">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-75" /> <span
<span className="relative inline-flex size-2.5 rounded-full bg-orange-500" /> className={cn(
</span></TooltipTrigger> "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
testModeLoading ? "bg-orange-500" : "bg-green-400"
)}
/>
<span
className={cn(
"relative inline-flex size-2.5 rounded-full",
testModeLoading ? "bg-orange-500" : "bg-green-500"
)}
/>
</span>
</TooltipTrigger>
<TooltipContent>Test mode was enabled.</TooltipContent> <TooltipContent>Test mode was enabled.</TooltipContent>
</Tooltip> </Tooltip>
<AnimatedText <AnimatedText
className="text-muted-foreground top-[2.5px] left-[6px] min-w-[70vw]" className="text-muted-foreground top-[2.5px] left-[6px] min-w-[70vw]"
text={testModeStatus} text={testModeStatus}
glimmer glimmer={testModeLoading}
/> />
</div> </div>
); );

@ -38,14 +38,12 @@ import { MultisessionAppSupport } from "@clerk/nextjs/internal";
export const ClerkProvider = ({ children }: { children: React.ReactNode }) => { export const ClerkProvider = ({ children }: { children: React.ReactNode }) => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
if (resolvedTheme === "dark") { if (resolvedTheme === "dark") {
console.log(resolvedTheme);
return ( return (
<ImportedClerkProvider appearance={{ baseTheme: dark }}> <ImportedClerkProvider appearance={{ baseTheme: dark }}>
<MultisessionAppSupport>{children}</MultisessionAppSupport> <MultisessionAppSupport>{children}</MultisessionAppSupport>
</ImportedClerkProvider> </ImportedClerkProvider>
); );
} }
console.log("a");
return ( return (
<ImportedClerkProvider> <ImportedClerkProvider>

@ -51,8 +51,7 @@ type ModDBCategory = {
export const serverModDB: ModDBCategory[] = [ export const serverModDB: ModDBCategory[] = [
{ {
displayTitle: "Custom Files", displayTitle: "Create Custom Files",
__custom: true,
description: description:
`Create custom TypeScript-based filter or sorting systems, completely from the comfort of your own browser. `Create custom TypeScript-based filter or sorting systems, completely from the comfort of your own browser.
Types used are *builtin* and you can see live type definitions and IntelliSense in the editor.`, Types used are *builtin* and you can see live type definitions and IntelliSense in the editor.`,
@ -73,4 +72,11 @@ export const serverModDB: ModDBCategory[] = [
}, },
], ],
}, },
{
displayTitle: "Custom Files",
description: "These are all of your activated modifications made in the editor.",
__custom: true,
// Entries are already pre-loaded.
entries: []
}
]; ];

@ -0,0 +1,92 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { clerkClient as ClerkClient, getAuth } from "@clerk/nextjs/server";
import { Db, MongoClient } from "mongodb";
import { NextApiRequest } from "next";
import { NextRequest, NextResponse } from "next/server";
import requestIp from 'request-ip'
type BackendProcedureValue = {
status: "BANNED" | "OK" | "BLOCKED",
allowed: boolean,
mongoClient?: MongoClient,
defaultDatabase?: Db
}
export async function getBackendProcedure(request: NextApiRequest): Promise<BackendProcedureValue> {
const mongoClient = new MongoClient(process.env.MONGO_DB as string);
const {userId} = getAuth(request)
await mongoClient.connect();
const defaultDatabase = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const clerkClient = await ClerkClient();
if (userId !== null) {
// User exists
const user = await clerkClient.users.getUser(userId);
const userBannedMetadata = user.publicMetadata.banned;
if (userBannedMetadata !== undefined) {
// User is banned
await mongoClient.close()
return {
status: "BANNED",
allowed: false
}
}
}
const detectedIp = requestIp.getClientIp(request);
if (detectedIp !== null) {
const collection = defaultDatabase.collection("blocked-ips");
console.log(await collection.findOne({ ip: detectedIp }), detectedIp)
if (await collection.findOne({ ip: detectedIp }) !== null) {
await mongoClient.close()
return { status: "BLOCKED", allowed: false }
}
}
await mongoClient.close()
return {
status: "OK",
allowed: true,
}
}
function convert(request: Headers) {
const headersObject: Record<string, string> = {};
for (const [key, value] of request) {
headersObject[key] = value;
}
return headersObject;
}

@ -39,16 +39,16 @@ export function useFilters(data: OnlineServer[]) {
const [filteredData, setFilteredData] = useState<OnlineServer[]>(data); const [filteredData, setFilteredData] = useState<OnlineServer[]>(data);
const [t] = useQueryState("tm"); const [t] = useQueryState("tm");
const [testModeEnabled, setTestModeEnabled] = useState(false); const [testModeEnabled, setTestModeEnabled] = useState(false);
const [testModeStatus, setTestModeStatus] = useState("Haven't connected thread yet (if stuck, select the other tab, and come back)"); const [testModeStatus, setTestModeStatus] = useState(
"Haven't connected thread yet (if stuck, select the other tab, and come back)",
);
const [testModeLoading, setTestModeLoading] = useState(true); const [testModeLoading, setTestModeLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (filteredData.length === 0) setFilteredData(data); if (filteredData.length === 0) setFilteredData(data);
}, [data, filteredData.length]); }, [data, filteredData.length]);
useEffect(() => { const testModeInit = (type: "filter" | "sort") => {
if (data.length !== 0)
window.addEventListener("test-mode.enable", (c) => {
window.dispatchEvent(new Event("test-mode.enabled")); window.dispatchEvent(new Event("test-mode.enabled"));
if (!t) { if (!t) {
toast.error("Couldn't enable test mode; no query variable."); toast.error("Couldn't enable test mode; no query variable.");
@ -59,11 +59,11 @@ export function useFilters(data: OnlineServer[]) {
setTestModeStatus("Transpiling TypeScript..."); setTestModeStatus("Transpiling TypeScript...");
const startTime = Date.now(); const startTime = Date.now();
const { error, data: transpiledCode } = await tryCatch( const { error, data: transpiledCode } = await tryCatch(
(async () => transpileTypeScript(code))() (async () => transpileTypeScript(code))(),
); );
if (error) { if (error) {
setTestModeStatus( setTestModeStatus(
"Failed to transpile TypeScript! Error: " + error.message "Failed to transpile TypeScript! Error: " + error.message,
); );
setTestModeLoading(false); setTestModeLoading(false);
return; return;
@ -73,17 +73,13 @@ export function useFilters(data: OnlineServer[]) {
setTestModeLoading(false); setTestModeLoading(false);
return; return;
} }
console.log(
"[MHSF Filters] Transpiled TypeScript:",
transpiledCode ?? ""
);
setTestModeStatus("Generating function..."); setTestModeStatus("Generating function...");
if ( if (
!transpiledCode.includes("export default") && !transpiledCode.includes("export default") &&
!transpiledCode.includes("export") !transpiledCode.includes("export")
) { ) {
setTestModeStatus( setTestModeStatus(
"Transpiled code does not contain any export statements." "Transpiled code does not contain any export statements.",
); );
setTestModeLoading(false); setTestModeLoading(false);
return; return;
@ -93,54 +89,82 @@ export function useFilters(data: OnlineServer[]) {
.replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon .replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon
const { error: filterErr, data: filterFunc } = await tryCatch( const { error: filterErr, data: filterFunc } = await tryCatch(
(async () => (async () =>
new Function( type === "filter"
? new Function(
"server", "server",
`${functionBody} `${functionBody}
return filter(server)` return filter(server)`,
))() )
: new Function(
"serverA",
"serverB",
`${functionBody}
return sort(serverA, serverB)`,
))(),
); );
if (filterErr) { if (filterErr) {
setTestModeStatus( setTestModeStatus(
`Failed to generate function! Error: ${filterErr.message}` `Failed to generate function! Error: ${filterErr.message}`,
); );
setTestModeLoading(false); setTestModeLoading(false);
return; return;
} }
if (typeof filterFunc === "function") { if (typeof filterFunc === "function") {
setTestModeStatus( setTestModeStatus("Compiled in " + (Date.now() - startTime) + "ms");
"Compiled in " + (Date.now() - startTime) + "ms"
);
toast.promise( toast.promise(
async () => { async () => {
let newServers = []; let newServers = [];
if (type === "filter") {
newServers = data.filter((c) => filterFunc(c)); newServers = data.filter((c) => filterFunc(c));
setTestModeStatus( setTestModeStatus(
"Server count " + data.length + " -> " + newServers.length "Server count " + data.length + " -> " + newServers.length,
);
if (newServers.length === 0)
setTestModeStatus(
"No servers were specified in the criteria; showing all servers instead",
); );
setFilteredData(() => [...newServers]); setFilteredData(() => [...newServers]);
}
if (type === "sort") {
newServers = data.sort((a, b) => filterFunc(a, b));
setTestModeStatus("Sorted " + newServers.length + " servers.");
console.log(newServers, data.sort((a, b) => filterFunc(a, b)))
console.log(filterFunc)
setFilteredData(() => [...newServers]);
}
setTestModeLoading(false); setTestModeLoading(false);
window.dispatchEvent(new Event("test-mode.success"));
}, },
{ {
loading: "Manipulating data...", loading: "Manipulating data...",
success: "Manipulated data; test mode finished!", success: "Manipulated data; test mode finished!",
error: (e) => error: (e) =>
`Error while manipulating data; go back to your editor and run again. ${e}`, `Error while manipulating data; go back to your editor and run again. ${e}`,
} },
); );
} else { } else {
setTestModeStatus( setTestModeStatus(
"Code doesn't have a 'filter' function. Cannot be tested." "Code doesn't have a 'filter' function. Cannot be tested.",
); );
setTestModeLoading(false); setTestModeLoading(false);
} }
})(); })();
} }
}); };
}, [t, data]);
console.log(filteredData, testModeStatus); useEffect(() => {
if (data.length !== 0) {
window.addEventListener("test-mode.enable.filter", () =>
testModeInit("filter"),
);
window.addEventListener("test-mode.enable.sort", () =>
testModeInit("sort"),
);
}
}, [t, data]);
return { filteredData, testModeEnabled, testModeLoading, testModeStatus }; return { filteredData, testModeEnabled, testModeLoading, testModeStatus };
} }

@ -53,7 +53,6 @@ export function useIframeCommunication(bottomIframe?: RefObject<HTMLIFrameElemen
}, },
handle: (key: string, callback: (object: any) => void) => { handle: (key: string, callback: (object: any) => void) => {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
console.log(e);
if (e.data.__key === key) { if (e.data.__key === key) {
callback(e.data) callback(e.data)
} }

@ -35,6 +35,7 @@ import {
} from "@clerk/nextjs/server"; } from "@clerk/nextjs/server";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import type { ServerResponse } from "./lib/types/mh-server"; import type { ServerResponse } from "./lib/types/mh-server";
import { getBackendProcedure } from "./lib/backend-procedure";
// Thanks for the router matcher API Clerk <3 // Thanks for the router matcher API Clerk <3
const isRootRoute = createRouteMatcher(["/"]); const isRootRoute = createRouteMatcher(["/"]);
@ -57,22 +58,6 @@ export default process.env.NEXT_PUBLIC_IS_AUTH === "true"
return NextResponse.redirect(new URL("/home", req.url)); return NextResponse.redirect(new URL("/home", req.url));
} }
} }
// If user is banned, disable all API routes
if (authRes.userId !== null) {
// User exists
const user = await client.users.getUser(authRes.userId);
const userBannedMetadata = user.publicMetadata.banned;
if (userBannedMetadata !== undefined) {
// User is banned
if (apiRoute(req)) {
return NextResponse.json({
banned:
"You were banned. (and I'm not telling you why) Why are you trying to use the API. Huh? Tell me. Now. You're not funny.",
});
}
}
}
if (isOldServerRoute(req)) { if (isOldServerRoute(req)) {
const minehut = await fetch( const minehut = await fetch(
`https://api.minehut.com/server/${req.url.split("/server/")[1].split("/")[0]}?byName=true`, `https://api.minehut.com/server/${req.url.split("/server/")[1].split("/")[0]}?byName=true`,

@ -28,6 +28,7 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getBackendProcedure } from "@/lib/backend-procedure";
import type { MHSFData } from "@/lib/types/data"; import type { MHSFData } from "@/lib/types/data";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@ -49,8 +50,16 @@ export type RouteParams = {
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<{ server: (MHSFData & RouteParams) | null }> res: NextApiResponse<
{ server: (MHSFData & RouteParams) | null } | { error: string }
>,
) { ) {
const backendProcedure = await getBackendProcedure(req);
if (backendProcedure.status !== "OK")
return res.status(403).json({
error: `Backend procedure marked request as '${backendProcedure.status}' instead of required 'OK'`,
});
const { const {
server, server,
maxFavoriteEntries, maxFavoriteEntries,
@ -127,7 +136,7 @@ export default async function handler(
async function findCustomizationData( async function findCustomizationData(
serverName: string, serverName: string,
userId: string | undefined, userId: string | undefined,
db: any db: any,
): Promise<{ ): Promise<{
description: string | undefined; description: string | undefined;
banner: string | undefined; banner: string | undefined;
@ -172,7 +181,7 @@ async function findFavoriteData(
maxFavoriteEntries?: string | string[]; maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[]; favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[]; favoriteTimespanEnd?: string | string[];
} },
) { ) {
// Run queries in parallel // Run queries in parallel
const [userFavorites, metaData, historyData] = await Promise.all([ const [userFavorites, metaData, historyData] = await Promise.all([
@ -204,7 +213,7 @@ async function fetchHistoryData(
maxFavoriteEntries?: string | string[]; maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[]; favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[]; favoriteTimespanEnd?: string | string[];
} },
) { ) {
// Build query filter // Build query filter
const filter: any = { server: serverName }; const filter: any = { server: serverName };
@ -235,7 +244,7 @@ async function fetchHistoryData(
} }
async function findServerData( async function findServerData(
server: string server: string,
): Promise<{ exists: boolean; name: string }> { ): Promise<{ exists: boolean; name: string }> {
try { try {
const response = await fetch("https://api.minehut.com/server/" + server); const response = await fetch("https://api.minehut.com/server/" + server);
@ -262,7 +271,7 @@ async function findPlayerData(
maxPlayerEntries?: string | string[]; maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[]; playerTimespanStart?: string | string[];
playerTimespanEnd?: string | string[]; playerTimespanEnd?: string | string[];
} },
) { ) {
// Get historical player data // Get historical player data
const historyCollection = db.collection("history"); const historyCollection = db.collection("history");
@ -304,7 +313,7 @@ async function findPlayerData(
(item: { date: string; player_count?: number }) => ({ (item: { date: string; player_count?: number }) => ({
date: item.date, date: item.date,
playerCount: item.player_count || 0, playerCount: item.player_count || 0,
}) }),
); );
const max = maxResult.length > 0 ? maxResult[0].player_count : 0; const max = maxResult.length > 0 ? maxResult[0].player_count : 0;
@ -319,7 +328,7 @@ async function findAchievements(
maxAchievementEntries?: string | string[]; maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[]; achievementTimespanStart?: string | string[];
achievementTimespanEnd?: string | string[]; achievementTimespanEnd?: string | string[];
} },
) { ) {
// Get achievements data // Get achievements data
const achievementsCollection = db.collection("achievements"); const achievementsCollection = db.collection("achievements");
@ -348,7 +357,7 @@ async function findAchievements(
const currently: any[] = []; const currently: any[] = [];
for (const a of historically) for (const a of historically)
a.achievements.forEach((item: any, interval: number) => a.achievements.forEach((item: any, interval: number) =>
currently.push({ interval, ...item }) currently.push({ interval, ...item }),
); );
return { historically, currently }; return { historically, currently };

@ -33,11 +33,18 @@ import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { OnlineServer } from "@/lib/types/mh-server"; import { OnlineServer } from "@/lib/types/mh-server";
import { waitUntil } from "@vercel/functions"; import { waitUntil } from "@vercel/functions";
import { getBackendProcedure } from "@/lib/backend-procedure";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
const backendProcedure = await getBackendProcedure(req);
if (backendProcedure.status !== "OK")
return res.status(403).json({
error: `Backend procedure marked request as '${backendProcedure.status}' instead of required 'OK'`,
});
const { userId } = getAuth(req); const { userId } = getAuth(req);
const { server } = req.query; const { server } = req.query;

@ -3223,11 +3223,43 @@
lodash.merge "^4.6.2" lodash.merge "^4.6.2"
postcss-selector-parser "6.0.10" postcss-selector-parser "6.0.10"
"@tanstack/query-core@5.69.0":
version "5.69.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.69.0.tgz#c434505987ade936dc53e6e27aa1406b0295516f"
integrity sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==
"@tanstack/react-query@^5.69.0":
version "5.69.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.69.0.tgz#8d58e800854cc11d0aa2c39569f53ae32ba442a9"
integrity sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==
dependencies:
"@tanstack/query-core" "5.69.0"
"@tootallnate/quickjs-emscripten@^0.23.0": "@tootallnate/quickjs-emscripten@^0.23.0":
version "0.23.0" version "0.23.0"
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
"@trpc/client@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/client/-/client-11.0.0.tgz#3020392edf87abc046594cee0acd379f2c6289d9"
integrity sha512-U2THlxsdr4ykAX5lpTU8k5WRADPQ+68Ex2gfUht3MlCxGK7njBmNSSzjpQSWNt7tMI/xsYrddFiRlmEPrh+Cbg==
"@trpc/next@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/next/-/next-11.0.0.tgz#d83e1fac595629c3bc02ba5bbe2595508b7c55ee"
integrity sha512-HpowgsF0jfXG30jEBVK8v90ltbEZiQZq/x0rsjScfZuedkAfapqZvrsrkzv6Pkemz7sxaxJcZB3HEqXxWfkGoA==
"@trpc/react-query@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/react-query/-/react-query-11.0.0.tgz#6fb849baf715fb33d2eb6a417d7e75fc00307ae6"
integrity sha512-HeE9bBLA6nqC2xk5wlNZIPQ5vmyli3tgNNab8fTE489+ksNMKxaIx66pZKsMJIorDcP1wS0rWNV+GroU0iR98g==
"@trpc/server@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/server/-/server-11.0.0.tgz#5a49758fa3c052a83314c328155d68027164f077"
integrity sha512-xY9q/b/wR/tWGYTm5xmRjivkYD2EZZXmOKmHuNJRYZuLbieeNUsdfQRjJC409WB1pjKWInomhHwuA8bahZJ4lQ==
"@types/acorn@^4.0.0": "@types/acorn@^4.0.0":
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22"
@ -3491,7 +3523,7 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react-dom@^19", "@types/react-dom@^19.0.3": "@types/react-dom@19.0.4", "@types/react-dom@^19", "@types/react-dom@^19.0.3":
version "19.0.4" version "19.0.4"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.4.tgz#bedba97f9346bd4c0fe5d39e689713804ec9ac89" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.4.tgz#bedba97f9346bd4c0fe5d39e689713804ec9ac89"
integrity sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg== integrity sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==
@ -3503,13 +3535,20 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react@*", "@types/react@^19", "@types/react@^19.0.8": "@types/react@*", "@types/react@19.0.10", "@types/react@^19", "@types/react@^19.0.8":
version "19.0.10" version "19.0.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb"
integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g== integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==
dependencies: dependencies:
csstype "^3.0.2" csstype "^3.0.2"
"@types/request-ip@^0.0.41":
version "0.0.41"
resolved "https://registry.yarnpkg.com/@types/request-ip/-/request-ip-0.0.41.tgz#c22a3244df2573402989346062851b06b7a5ac4e"
integrity sha512-Qzz0PM2nSZej4lsLzzNfADIORZhhxO7PED0fXpg4FjXiHuJ/lMyUg+YFF5q8x9HPZH3Gl6N+NOM8QZjItNgGKg==
dependencies:
"@types/node" "*"
"@types/resolve@^1.17.1": "@types/resolve@^1.17.1":
version "1.20.6" version "1.20.6"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8"
@ -4764,6 +4803,13 @@ cookie@~0.7.2:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
copy-anything@^3.0.2:
version "3.0.5"
resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0"
integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==
dependencies:
is-what "^4.1.8"
core-util-is@^1.0.3: core-util-is@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
@ -7637,6 +7683,11 @@ is-weakset@^2.0.3:
call-bound "^1.0.3" call-bound "^1.0.3"
get-intrinsic "^1.2.6" get-intrinsic "^1.2.6"
is-what@^4.1.8:
version "4.1.16"
resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f"
integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==
is-wsl@^2.2.0: is-wsl@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
@ -10877,6 +10928,11 @@ repeat-string@^1.6.1:
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==
request-ip@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/request-ip/-/request-ip-3.3.0.tgz#863451e8fec03847d44f223e30a5d63e369fa611"
integrity sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==
require-directory@^2.1.1: require-directory@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -11677,6 +11733,13 @@ sucrase@^3.32.0, sucrase@^3.35.0:
pirates "^4.0.1" pirates "^4.0.1"
ts-interface-checker "^0.1.9" ts-interface-checker "^0.1.9"
superjson@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.2.tgz#9d52bf0bf6b5751a3c3472f1292e714782ba3173"
integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==
dependencies:
copy-anything "^3.0.2"
supports-color@^7.1.0: supports-color@^7.1.0:
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@ -12042,7 +12105,7 @@ turbo-windows-arm64@2.4.4:
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.4.4.tgz#e00c26e3d7fd9a82af90018ad3137f14e5221630" resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.4.4.tgz#e00c26e3d7fd9a82af90018ad3137f14e5221630"
integrity sha512-403sqp9t5sx6YGEC32IfZTVWkRAixOQomGYB8kEc6ZD+//LirSxzeCHCnM8EmSXw7l57U1G+Fb0kxgTcKPU/Lg== integrity sha512-403sqp9t5sx6YGEC32IfZTVWkRAixOQomGYB8kEc6ZD+//LirSxzeCHCnM8EmSXw7l57U1G+Fb0kxgTcKPU/Lg==
turbo@^2.4.0, turbo@^2.4.2: turbo@^2.4.0, turbo@^2.4.4:
version "2.4.4" version "2.4.4"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.4.4.tgz#cec5dbac5850adebdba71fbdf90e6e9a7723c3d6" resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.4.4.tgz#cec5dbac5850adebdba71fbdf90e6e9a7723c3d6"
integrity sha512-N9FDOVaY3yz0YCOhYIgOGYad7+m2ptvinXygw27WPLQvcZDl3+0Sa77KGVlLSiuPDChOUEnTKE9VJwLSi9BPGQ== integrity sha512-N9FDOVaY3yz0YCOhYIgOGYad7+m2ptvinXygw27WPLQvcZDl3+0Sa77KGVlLSiuPDChOUEnTKE9VJwLSi9BPGQ==
@ -12867,7 +12930,7 @@ zod@3.23.8:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zod@^3.20.6, zod@^3.21.4, zod@^3.23.8, zod@^3.24.1: zod@^3.20.6, zod@^3.21.4, zod@^3.24.1, zod@^3.24.2:
version "3.24.2" version "3.24.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3"
integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ== integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==