Compare commits
4
Commits
No files matched your search
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Auth proxy clones multipart requests; its 10 MB default truncates uploads.
|
||||
experimental: { proxyClientMaxBodySize: "260mb" },
|
||||
// The panel is self-hosted on a LAN; no image CDN available.
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="Install the exact From the Fog Reimagined pack and join our Minecraft Java server.">
|
||||
<title>The Fog — Pack & Play</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#0b1110;--fg:#e5eee8;--muted:#a3b2aa;--line:#2c3b33;--green:#c4f29d}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.6}a{color:inherit;text-underline-offset:4px}button,a{touch-action:manipulation}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:5px}header,main,footer{width:min(1120px,calc(100% - 48px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:27px 0;border-bottom:1px solid var(--line)}.brand{font-weight:800;letter-spacing:.15em;font-size:14px;text-decoration:none}.brand span{color:var(--green)}.mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;letter-spacing:.07em}.muted{color:var(--muted)}.hero{padding:84px 0 62px;position:relative;isolation:isolate;overflow:hidden}.hero:after{content:"";position:absolute;z-index:-1;width:56%;height:400px;right:-8%;top:5%;background:repeating-linear-gradient(90deg,transparent 0 34px,#36503b26 35px 52px,transparent 53px 70px),radial-gradient(ellipse,#6b84502b,transparent 65%);transform:skewY(-7deg);mask-image:linear-gradient(90deg,transparent,#000)}.eyebrow{color:var(--green);display:flex;align-items:center;gap:10px}.eyebrow:before{content:"";height:7px;width:7px;background:var(--green)}h1{font-size:clamp(44px,7.5vw,88px);line-height:1.04;letter-spacing:-.06em;margin:24px 0 27px;font-weight:650;max-width:800px}h1 em{font-style:normal;color:var(--green)}.intro{max-width:540px;font-size:18px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:30px}.button{display:inline-flex;align-items:center;justify-content:center;gap:16px;min-height:48px;padding:10px 19px;background:var(--green);color:#13200e;border:1px solid var(--green);font-family:inherit;font-weight:600;font-size:14px;text-decoration:none;border-radius:4px;cursor:pointer}.button.secondary{background:#121b16;color:var(--fg);border-color:var(--line)}.versions{display:flex;flex-wrap:wrap;gap:8px 28px;padding:22px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.versions span{color:var(--muted)}.versions b{color:var(--fg);font-weight:500}.section-title{display:flex;gap:20px;align-items:baseline;margin:64px 0 20px}.section-title h2{font-size:25px;letter-spacing:-.035em;margin:0}.steps{border-top:1px solid var(--line)}article{display:grid;grid-template-columns:64px 280px 1fr;gap:16px;border-bottom:1px solid var(--line);padding:30px 0}.num{font-family:ui-monospace,monospace;color:var(--green);font-size:14px}h3{font-size:20px;line-height:1.3;letter-spacing:-.02em;margin:0 0 10px}p{margin:0 0 14px}article p{color:var(--muted)}.detail{max-width:590px}.detail a{color:var(--fg)}.note{font-size:13px}.server-box{display:flex;align-items:center;justify-content:space-between;border:1px solid #536645;background:#162014;padding:15px 18px;margin-top:18px;gap:15px;border-radius:4px}.server-box code{font-size:clamp(13px,2vw,17px);overflow-wrap:anywhere}.copy{background:none;border:0;color:var(--green);font-weight:600;font-size:13px;cursor:pointer;min-height:30px;white-space:nowrap}code{font-family:ui-monospace,SFMono-Regular,monospace}details{border-bottom:1px solid var(--line);padding:18px 0}summary{cursor:pointer;list-style-position:outside;margin-left:18px;font-weight:550}details p{margin:14px 0 4px 18px;color:var(--muted);max-width:760px}.download{padding:30px;border:1px solid var(--line);border-radius:5px;margin:45px 0;background:#111a15;display:flex;justify-content:space-between;align-items:center;gap:24px}.download h3{margin-bottom:6px}.download p{margin:0;font-size:14px;color:var(--muted)}footer{padding:32px 0 42px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:20px;font-size:12px;color:var(--muted)}.toast{position:fixed;bottom:25px;left:50%;transform:translateX(-50%);background:var(--green);color:#13200e;padding:10px 20px;border-radius:4px;box-shadow:0 8px 35px #0008}.toast:empty{display:none}@media(max-width:760px){header,main,footer{width:calc(100% - 32px)}header .mono{font-size:10px}.hero{padding:52px 0 40px}.intro{font-size:16px}article{grid-template-columns:35px 1fr;gap:10px}.detail{grid-column:2}.section-title{margin-top:44px}.download{padding:22px;display:block}.download .button{margin-top:20px}footer{display:block}footer p{margin-bottom:10px}.versions{gap:8px 18px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><a class="brand" href="#">THE <span>FOG</span> / PACK & PLAY</a><span class="mono muted">JAVA EDITION · FRIENDS SERVER</span></header>
|
||||
<main>
|
||||
<section class="hero"><div class="eyebrow mono">FROM THE FOG REIMAGINED · 6.3.2</div><h1>Get in.<br><em>Stay together.</em></h1><p class="intro">One matching modpack. One server address. Everything you need to join the new world, without collecting a folder of mods.</p><div class="actions"><a class="button" href="#install">Install the pack <span aria-hidden="true">↗</span></a><a class="button secondary" href="#join">I’m ready to join <span aria-hidden="true">↓</span></a></div></section>
|
||||
<div class="versions mono"><div><span>PACK</span> <b>6.3.2</b></div><div><span>MINECRAFT</span> <b>1.20.1</b></div><div><span>LOADER</span> <b>FORGE 47.4.10</b></div><div><span>JAVA</span> <b>17</b></div></div>
|
||||
<section id="install" aria-labelledby="install-title"><div class="section-title"><span class="mono muted">01—03</span><h2 id="install-title">Three steps to the fog.</h2></div><div class="steps">
|
||||
<article><span class="num">01</span><div><h3>Open your launcher.</h3><span class="mono muted">INSTALL ONCE</span></div><div class="detail"><p>Using an offline username? <a href="https://docs.skmedix.pl/4.0/" target="_blank" rel="noopener">SKlauncher 4.0</a> has a CurseForge modpack installer and automatic Java setup. Version 4.0 is currently beta.</p><p>If you play with Microsoft sign-in, you can also use the <a href="https://www.curseforge.com/download/app" target="_blank" rel="noopener">CurseForge app</a>.</p><p class="note">Already have a compatible launcher? Keep it. You only need its CurseForge pack installer or ZIP import.</p></div></article>
|
||||
<article><span class="num">02</span><div><h3>Install the whole pack.</h3><span class="mono muted">PINNED TO 6.3.2</span></div><div class="detail"><p>Search <strong>From the Fog Reimagined</strong> by <strong>SoumXD</strong>. Select version <strong>6.3.2</strong>, then install and launch that profile.</p><p><a href="https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined/files/9191040" target="_blank" rel="noopener">Open the exact official release ↗</a></p><p class="note">The launcher downloads the mods for you. Allocate 6 GB of memory if your computer has room (the author’s minimum is 4 GB). Shaders are optional.</p></div></article>
|
||||
<article id="join"><span class="num">03</span><div><h3>Add the server.</h3><span class="mono muted">MULTIPLAYER → ADD SERVER</span></div><div class="detail"><p>Launch the pack, open Multiplayer, and paste this address. Use the exact username the admin approved, including capitalization.</p><div class="server-box"><code id="server-address">minecraft.ayush-jain.dev</code><button class="copy" id="copy-address">Copy address</button></div><p class="note" style="margin-top:16px">Ask the admin to open your first-join registration window in CraftDeck. On first join, follow the prompt: <code>/login YOUR_PASSWORD YOUR_PASSWORD</code>. Later, use <code>/login YOUR_PASSWORD</code>. Choose your password in the game.</p></div></article>
|
||||
</div></section>
|
||||
<section aria-labelledby="help-title"><div class="section-title"><span class="mono muted">FIELD NOTES</span><h2 id="help-title">Stuck at the treeline?</h2></div><details><summary>“You are not whitelisted”</summary><p>Use your approved username exactly. The initial names are <code>aryan</code> and <code>jainster</code>. Ask the admin to approve or reopen your name in CraftDeck → Access & Auth immediately before joining.</p></details><details><summary>Missing mods or incompatible version</summary><p>Launch the complete Reimagined 6.3.2 profile with Minecraft 1.20.1 and Forge 47.4.10. The standalone From the Fog datapack and the latest vanilla Minecraft are different installations.</p></details><details><summary>The game runs slowly or closes</summary><p>Disable shaders first, then check the pack’s memory setting. The author lists 4 GB as the client minimum; 6 GB is useful when your computer has enough spare memory.</p></details><details><summary>I forgot my server password</summary><p>Ask the admin to use Re-register password in CraftDeck → Access & Auth. Rejoin during the new approval window and choose your own replacement password in the game.</p></details></section>
|
||||
<section class="download"><div><h3>Need a ZIP for your launcher?</h3><p>Get the exact client manifest from CurseForge, then use your launcher’s pack import.<br>This is the client pack—not the server archive.</p></div><a class="button secondary" href="https://www.curseforge.com/api/v1/mods/964725/files/9191040/download">Download client pack <span aria-hidden="true">↓</span></a></section>
|
||||
</main>
|
||||
<footer><p>Pack by <a href="https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined" target="_blank" rel="noopener">SoumXD</a>. Mod files are downloaded from their official sources.</p><p>Official pack baseline · CaseOh’s exact custom setup is unverified.</p></footer>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
const copy=document.getElementById('copy-address'),toast=document.getElementById('toast');let timer;
|
||||
copy.addEventListener('click',async()=>{const address=document.getElementById('server-address').textContent;try{await navigator.clipboard.writeText(address);copy.textContent='Copied';toast.textContent='Server address copied';}catch{const selection=window.getSelection(),range=document.createRange();range.selectNodeContents(document.getElementById('server-address'));selection.removeAllRanges();selection.addRange(range);toast.textContent='Address selected. Press Ctrl+C or ⌘C to copy.';}clearTimeout(timer);timer=setTimeout(()=>{copy.textContent='Copy address';toast.textContent='';},3000);});
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -21,6 +21,8 @@ type AccessData = {
|
||||
approved: ApprovedPlayer[];
|
||||
whitelist: string[] | null;
|
||||
registered: string[] | null;
|
||||
authProvider: "easyauth" | "serverlogin";
|
||||
registrationCommand: string;
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
|
||||
@@ -80,7 +82,7 @@ export default function AccessPage() {
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Access & Auth</h1>
|
||||
<p className="mt-0.5 text-xs text-ink2">
|
||||
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
|
||||
Only approved usernames can join. New players must register within the window using <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>, then{" "}
|
||||
<code>/login</code> on every visit.
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,7 +107,7 @@ export default function AccessPage() {
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted">
|
||||
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
|
||||
<em>right away</em> and run <code>/register <password> <password></code>. If the timer
|
||||
<em>right away</em> and run <code>{data?.registrationCommand ?? "/register <password> <password>"}</code>. If the timer
|
||||
expires first, the name is automatically removed from the whitelist.
|
||||
</p>
|
||||
</Card>
|
||||
@@ -183,7 +185,7 @@ export default function AccessPage() {
|
||||
<RefreshCw size={12} /> Re-open window
|
||||
</Button>
|
||||
)}
|
||||
{p.status === "registered" && (
|
||||
{p.status === "registered" && data?.authProvider !== "serverlogin" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -205,6 +207,11 @@ export default function AccessPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{p.status === "registered" && data?.authProvider === "serverlogin" && (
|
||||
<Button size="sm" onClick={() => void run(`reregister-${p.username}`, { op: "force-reregister", username: p.username })} busy={busy === `reregister-${p.username}`}>
|
||||
<KeyRound size={12} /> Re-register password
|
||||
</Button>
|
||||
)}
|
||||
{p.status !== "revoked" && (
|
||||
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
|
||||
Revoke
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
|
||||
import { api, useSSE, toast } from "@/lib/api";
|
||||
import { api, useSSE, useApi, toast } from "@/lib/api";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
|
||||
const QUICK_COMMANDS = [
|
||||
@@ -24,6 +24,9 @@ function colorFor(line: string): string {
|
||||
}
|
||||
|
||||
export default function ConsolePage() {
|
||||
const [server, setServer] = useState("primary");
|
||||
const [status, setStatus] = useState("Connecting…");
|
||||
const { data: targets } = useApi<Array<{ id: string; name: string; running: boolean }>>("/api/console");
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
@@ -33,7 +36,9 @@ export default function ConsolePage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useSSE("/api/stream/logs", {
|
||||
useEffect(() => { setLines([]); setStatus("Connecting…"); }, [server]);
|
||||
useSSE(`/api/stream/logs?server=${encodeURIComponent(server)}`, {
|
||||
status: value => setStatus(String(value)),
|
||||
message: (line) => {
|
||||
setLines((xs) => {
|
||||
const next = [...xs, line as string];
|
||||
@@ -60,7 +65,9 @@ export default function ConsolePage() {
|
||||
if (!c) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("/api/console", { body: { command: c } });
|
||||
const target = server;
|
||||
const result = await api<{ output: string }>("/api/console", { body: { command: c, server: target } });
|
||||
setLines(xs => [...xs, `> ${c}`, ...result.output.trim().split("\n").filter(Boolean)]);
|
||||
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
|
||||
setHistIdx(-1);
|
||||
setCommand("");
|
||||
@@ -97,9 +104,12 @@ export default function ConsolePage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Console</h1>
|
||||
<p className="mt-0.5 text-xs text-ink2">Live server log with RCON command input</p>
|
||||
<p className="mt-0.5 text-xs text-ink2">{status}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select aria-label="Console server" value={server} disabled={busy} onChange={e => setServer(e.target.value)} className="rounded-lg border border-border bg-surface2 p-2 text-sm">
|
||||
{(targets ?? [{ id: "primary", name: "Primary server", running: false }]).map(t => <option key={t.id} value={t.id}>{t.name}{t.running ? "" : " (stopped)"}</option>)}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Filter lines…"
|
||||
value={filter}
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function FilesPage() {
|
||||
const [mkdirOpen, setMkdirOpen] = useState(false);
|
||||
const [mkdirName, setMkdirName] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async (p: string) => {
|
||||
@@ -92,18 +93,18 @@ export default function FilesPage() {
|
||||
};
|
||||
|
||||
const upload = async (file: File) => {
|
||||
if (file.size > 256 * 1024 * 1024) { toast("File too large (256 MB max)", "error"); return; }
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("dir", path);
|
||||
form.append("file", file);
|
||||
toast(`Uploading ${file.name}…`, "info");
|
||||
form.append("dir", path); form.append("file", file);
|
||||
const res = await fetch("/api/files/upload", { method: "POST", body: form });
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.ok === false) {
|
||||
toast(payload.error ?? "Upload failed", "error");
|
||||
} else {
|
||||
const payload = await res.json().catch(() => ({ error: res.status === 413 ? "Upload rejected by the web proxy: file is too large" : `Upload failed (${res.status})` }));
|
||||
if (!res.ok || payload.ok === false) throw new Error(payload.error ?? "Upload failed");
|
||||
toast(`${file.name} uploaded`, "success");
|
||||
void load(path);
|
||||
}
|
||||
} catch (err) { toast((err as Error).message, "error"); }
|
||||
finally { setUploading(false); }
|
||||
};
|
||||
|
||||
const crumbs = path ? path.split("/") : [];
|
||||
@@ -126,7 +127,7 @@ export default function FilesPage() {
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Button disabled={managed || uploading} busy={uploading} size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Upload size={13} /> Upload here
|
||||
</Button>
|
||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
|
||||
@@ -728,7 +728,7 @@ function ProfileForm({
|
||||
setP({
|
||||
...p,
|
||||
loader,
|
||||
route: proxyCompatible(loader) ? p.route : "direct",
|
||||
route: proxyCompatible(loader) || p.route === "internal" ? p.route : "direct",
|
||||
modpack: loader === "MODRINTH" ? p.modpack : "",
|
||||
map: ["VANILLA", "MODRINTH"].includes(loader)
|
||||
? "none"
|
||||
@@ -810,16 +810,17 @@ function ProfileForm({
|
||||
Connection
|
||||
<Select
|
||||
value={p.route}
|
||||
onChange={(v) => setP({ ...p, route: v as "proxy" | "direct" })}
|
||||
onChange={(v) => setP({ ...p, route: v as WorldProfile["route"] })}
|
||||
options={[
|
||||
...(proxyCompatible(p.loader)
|
||||
? [{ value: "proxy", label: "Through Velocity" }]
|
||||
: []),
|
||||
{ value: "direct", label: "Direct game port" },
|
||||
{ value: "internal", label: "Private network / existing gateway" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{p.route === "direct"
|
||||
{p.route === "internal" ? <p className="text-xs text-muted">Private Docker network only. Connect through your existing gateway.</p> : p.route === "direct"
|
||||
? field("port", "Public game port", "number")
|
||||
: field(
|
||||
"hostname",
|
||||
@@ -1048,6 +1049,7 @@ function ServerTools({
|
||||
);
|
||||
}
|
||||
function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
const [uploadDir, setUploadDir] = useState("mods");
|
||||
const [path, setPath] = useState("");
|
||||
const [entries, setEntries] = useState<
|
||||
Array<{ name: string; directory: boolean; link: boolean }>
|
||||
@@ -1146,8 +1148,10 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
))}
|
||||
</div>
|
||||
<label className={fieldStyle}>
|
||||
Upload into /
|
||||
{path || " (open mods, plugins, or world/datapacks first)"}
|
||||
Upload destination (100 MB per file)
|
||||
<select aria-label="Upload destination" value={uploadDir} onChange={e => setUploadDir(e.target.value)}>
|
||||
<option value="mods">Mods (.jar)</option><option value="plugins">Plugins (.jar)</option><option value="world/datapacks">Datapacks (.zip)</option>
|
||||
</select>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jar,.zip"
|
||||
@@ -1155,19 +1159,21 @@ function ProfileFiles({ id, locked }: { id: string; locked: boolean }) {
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 100 * 1024 * 1024) { setError("Choose a file under 100 MB"); e.target.value = ""; return; }
|
||||
if (!(uploadDir === "world/datapacks" ? /\.zip$/i : /\.jar$/i).test(file.name)) { setError("Choose a JAR for mods/plugins or a ZIP for datapacks"); e.target.value = ""; return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("id", id);
|
||||
form.set("path", [path, file.name].filter(Boolean).join("/"));
|
||||
form.set("path", `${uploadDir}/${file.name}`);
|
||||
form.set("file", file);
|
||||
const res = await fetch("/api/network/files", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const result = await res.json();
|
||||
const result = await res.json().catch(() => ({ error: res.status === 413 ? "Upload rejected by the web proxy: file is too large" : `Upload failed (${res.status})` }));
|
||||
if (!res.ok) throw new Error(result.error || "Upload failed");
|
||||
await load(path);
|
||||
await load(uploadDir);
|
||||
toast("Uploaded", "success");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
forceReregister,
|
||||
importFromWhitelist,
|
||||
} from "@/server/authflow";
|
||||
import { whitelistList, easyauthList } from "@/server/mc";
|
||||
import { whitelistList, easyauthList, authProvider, registrationCommand } from "@/server/mc";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -23,7 +23,7 @@ export async function GET() {
|
||||
try {
|
||||
registered = await easyauthList();
|
||||
} catch {}
|
||||
return ok({ approved: listApproved(), whitelist, registered });
|
||||
return ok({ approved: listApproved(), whitelist, registered, authProvider: authProvider(), registrationCommand: registrationCommand() });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { consoleTarget } from "@/server/console-target";
|
||||
import { inspectContainer } from "@/server/docker";
|
||||
import { readNetwork, NetworkInputError } from "@/server/network-config";
|
||||
import { profileConsole } from "@/server/network";
|
||||
import { NextRequest } from "next/server";
|
||||
import { rcon, stripColors } from "@/server/rcon";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { pushLogLine } from "@/server/bus";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
const targets = [{ id: "primary", name: "Primary server" }, ...readNetwork().profiles.map(({ id, name }) => ({ id, name }))];
|
||||
return ok(await Promise.all(targets.map(async (target) => ({ ...target, ...await inspectContainer(consoleTarget(target.id)) }))));
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { command } = await readJson<{ command?: string }>(req);
|
||||
if (!command?.trim()) return fail("Empty command");
|
||||
const { command, server = "primary" } = await readJson<{ command?: string; server?: string }>(req);
|
||||
if (typeof command !== "string" || !command.trim() || command.length > 2000 || /[\r\n\0]/.test(command)) return fail("Enter one console command");
|
||||
try { consoleTarget(server); } catch (e) { if (e instanceof NetworkInputError) return fail(e.message); throw e; }
|
||||
if (!(await inspectContainer(consoleTarget(server))).running) return fail("This server is stopped. Start it from Overview or Servers first.", 409);
|
||||
const cmd = command.trim().replace(/^\//, "");
|
||||
const output = stripColors(await rcon().exec(cmd));
|
||||
const output = stripColors(server === "primary" ? await rcon().exec(cmd) : await profileConsole(server, cmd));
|
||||
logActivity("console", null, cmd);
|
||||
// Echo the exchange into the live console stream so it reads like a terminal.
|
||||
pushLogLine(`> ${cmd}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
if (Number(req.headers.get("content-length") || 0) > MAX_UPLOAD + 1024 * 1024) return fail("File too large (256 MB max)", 413);
|
||||
const form = await req.formData();
|
||||
const dir = (form.get("dir") as string) ?? "";
|
||||
const file = form.get("file");
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
|
||||
const isUpload = req.headers
|
||||
.get("content-type")
|
||||
?.includes("multipart/form-data");
|
||||
if (Number(req.headers.get("content-length") || 0) > 100 * 1024 * 1024)
|
||||
if (Number(req.headers.get("content-length") || 0) > 101 * 1024 * 1024)
|
||||
return fail("Upload limit is 100 MB");
|
||||
const form = isUpload ? await req.formData() : null;
|
||||
const body = form
|
||||
@@ -59,8 +59,8 @@ export async function POST(req: NextRequest) {
|
||||
return fail("Choose a file under 100 MB");
|
||||
const rel = path.relative(networkFilePath(p.id, ""), file);
|
||||
if (
|
||||
!/^(mods|plugins)\/[^/]+\.jar$/.test(rel) &&
|
||||
!/^world\/datapacks\/[^/]+\.zip$/.test(rel)
|
||||
!/^(mods|plugins)\/[^/]+\.jar$/i.test(rel) &&
|
||||
!/^world\/datapacks\/[^/]+\.zip$/i.test(rel)
|
||||
)
|
||||
return fail(
|
||||
"Upload JARs into mods/plugins or ZIPs into world/datapacks",
|
||||
|
||||
@@ -1,51 +1,64 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bus, logRing } from "@/server/bus";
|
||||
import { streamContainerLogs, inspectContainer, type LogStreamHandle } from "@/server/docker";
|
||||
import { consoleTarget } from "@/server/console-target";
|
||||
import { env } from "@/server/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** Live console stream (SSE). Backfills recent lines, then follows. */
|
||||
/** Each viewer follows its selected container, independent of the primary watcher. */
|
||||
export async function GET(req: NextRequest) {
|
||||
let name: string;
|
||||
try { name = consoleTarget(req.nextUrl.searchParams.get("server")); }
|
||||
catch { return Response.json({ error: "Unknown server" }, { status: 400 }); }
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
let cleanup = () => {};
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (line: string) => {
|
||||
let closed = false;
|
||||
let logs: LogStreamHandle | undefined;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
const send = (event: string, value: string) => {
|
||||
if (closed) return;
|
||||
try { controller.enqueue(encoder.encode(`${event === "message" ? "" : `event: ${event}\n`}data: ${JSON.stringify(value.split(env.rconPassword).join("[redacted]"))}\n\n`)); }
|
||||
catch { cleanup(); }
|
||||
};
|
||||
const follow = async (tail: number) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
const state = await inspectContainer(name);
|
||||
if (closed) return;
|
||||
send("status", state.running ? "Connected" : "Server stopped — showing its last logs. Start it from Overview or Servers.");
|
||||
if (!state.exists) throw new Error("Container has not been deployed");
|
||||
logs = streamContainerLogs(name, { tail, follow: true }, line => send("message", line), err => {
|
||||
if (closed) return;
|
||||
if (err) send("status", err.message);
|
||||
retry = setTimeout(() => void follow(0), 5000);
|
||||
});
|
||||
} catch (e) {
|
||||
send("status", (e as Error).message);
|
||||
if (!closed) retry = setTimeout(() => void follow(tail), 5000);
|
||||
}
|
||||
};
|
||||
for (const line of logRing()) send(line);
|
||||
const onLog = (line: string) => send(line);
|
||||
bus().on("log", onLog);
|
||||
const keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
if (!closed) { try { controller.enqueue(encoder.encode(": keepalive\n\n")); } catch { cleanup(); } }
|
||||
}, 15000);
|
||||
req.signal.addEventListener("abort", () => {
|
||||
cleanup = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
bus().off("log", onLog);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
clearTimeout(retry);
|
||||
logs?.stop();
|
||||
req.signal.removeEventListener("abort", cleanup);
|
||||
try { controller.close(); } catch {}
|
||||
};
|
||||
req.signal.addEventListener("abort", cleanup, { once: true });
|
||||
if (req.signal.aborted) cleanup(); else void follow(200);
|
||||
},
|
||||
cancel() { cleanup(); },
|
||||
});
|
||||
return new Response(stream, { headers: {
|
||||
"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive", "X-Accel-Buffering": "no",
|
||||
} });
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export type WorldProfile = {
|
||||
simulationDistance: number;
|
||||
pvp: boolean;
|
||||
hardcore: boolean;
|
||||
route: "proxy" | "direct";
|
||||
route: "proxy" | "direct" | "internal";
|
||||
port: number;
|
||||
hostname: string;
|
||||
mods: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
easyauthList,
|
||||
easyauthRemove,
|
||||
easyauthSetPassword,
|
||||
registrationCommand,
|
||||
} from "./mc";
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export async function approvePlayer(username: string, note?: string): Promise<Ap
|
||||
)
|
||||
.run(username, now, expires, note ?? null);
|
||||
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
|
||||
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
|
||||
notify("info", `Approved ${username}`, `They can now join and use ${registrationCommand()} — the window is ticking.`);
|
||||
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ export async function resetPassword(username: string): Promise<string> {
|
||||
export async function forceReregister(username: string): Promise<void> {
|
||||
await easyauthRemove(username);
|
||||
try {
|
||||
await kickPlayer(username, "Please rejoin and /register a new password");
|
||||
await kickPlayer(username, `Please rejoin and use ${registrationCommand()} to choose a new password`);
|
||||
} catch {}
|
||||
await whitelistAdd(username);
|
||||
db()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { env } from "./env";
|
||||
import { readNetwork, profileById } from "./network-config";
|
||||
import { serverName } from "./network-render";
|
||||
|
||||
/** Resolve registered targets only; never accept a Docker name from a client. */
|
||||
export function consoleTarget(id: string | null) {
|
||||
if (!id || id === "primary") return env.mcContainer;
|
||||
return serverName(profileById(readNetwork(), id).id);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import http from "node:http";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { appleInspect, applePower, appleExec, appleEnvironment, appleStats, appleLogStream, appleInfrastructure } from "./apple-container";
|
||||
import { env } from "./env";
|
||||
import { withServerOperation } from "./operation";
|
||||
@@ -201,31 +202,41 @@ export async function containerStats(name: string): Promise<ContainerStats> {
|
||||
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
||||
* [type, 0, 0, 0, len_be32] followed by len payload bytes.
|
||||
*/
|
||||
function createDemuxer(onLine: (line: string) => void) {
|
||||
export function createDemuxer(onLine: (line: string) => void) {
|
||||
let buf = Buffer.alloc(0);
|
||||
let textCarry = "";
|
||||
return (chunk: Buffer) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (buf.length >= 8) {
|
||||
const first = buf[0];
|
||||
// Frames start with stream type 0/1/2; if not, the container was
|
||||
// started with a TTY and the stream is raw text.
|
||||
if (first > 2) {
|
||||
textCarry += buf.toString("utf8");
|
||||
buf = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
const len = buf.readUInt32BE(4);
|
||||
if (buf.length < 8 + len) break;
|
||||
textCarry += buf.subarray(8, 8 + len).toString("utf8");
|
||||
buf = buf.subarray(8 + len);
|
||||
}
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let raw = false;
|
||||
const lines = () => {
|
||||
let nl;
|
||||
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
||||
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
||||
textCarry = textCarry.slice(nl + 1);
|
||||
}
|
||||
};
|
||||
const feed = (chunk: Buffer) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
if (buf.length && buf[0] > 2) raw = true;
|
||||
if (raw) {
|
||||
textCarry += decoder.write(buf);
|
||||
buf = Buffer.alloc(0);
|
||||
} else {
|
||||
while (buf.length >= 8) {
|
||||
const len = buf.readUInt32BE(4);
|
||||
if (buf.length < 8 + len) break;
|
||||
textCarry += decoder.write(buf.subarray(8, 8 + len));
|
||||
buf = buf.subarray(8 + len);
|
||||
}
|
||||
}
|
||||
lines();
|
||||
};
|
||||
feed.end = () => {
|
||||
textCarry += decoder.end();
|
||||
lines();
|
||||
if (textCarry) onLine(textCarry.replace(/\r$/, ""));
|
||||
textCarry = "";
|
||||
};
|
||||
return feed;
|
||||
}
|
||||
|
||||
export type LogStreamHandle = { stop: () => void };
|
||||
@@ -251,7 +262,7 @@ export function streamContainerLogs(
|
||||
}
|
||||
const demux = createDemuxer(onLine);
|
||||
res.on("data", (c: Buffer) => demux(c));
|
||||
res.on("end", () => onEnd?.());
|
||||
res.on("end", () => { demux.end(); if (!stopped) onEnd?.(); });
|
||||
res.on("error", (e) => onEnd?.(e as Error));
|
||||
});
|
||||
req.on("error", (e) => {
|
||||
@@ -293,7 +304,7 @@ export async function execInContainer(
|
||||
text += line + "\n";
|
||||
});
|
||||
res.on("data", (c: Buffer) => demux(c));
|
||||
res.on("end", () => resolve(text));
|
||||
res.on("end", () => { demux.end(); resolve(text); });
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -384,9 +384,30 @@ export async function kickAll(reason: string): Promise<number> {
|
||||
|
||||
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
export function authProvider() {
|
||||
return fs.existsSync(path.join(env.mcDataDir, "config", "serverlogin-common.toml")) ? "serverlogin" : "easyauth";
|
||||
}
|
||||
export function registrationCommand() {
|
||||
return authProvider() === "serverlogin" ? "/login <password> <password>" : "/register <password> <password>";
|
||||
}
|
||||
|
||||
export function parseServerLoginAccounts(text: string): string[] {
|
||||
const data = JSON.parse(text) as { formatVersion?: number; users?: Record<string, { lastKnownName?: string }> };
|
||||
if (data.formatVersion !== 1 || !data.users || Array.isArray(data.users) || typeof data.users !== "object") throw new Error("Unsupported Server Login account database");
|
||||
return Object.values(data.users).map(account => account?.lastKnownName).filter((name): name is string => typeof name === "string" && isValidUsername(name));
|
||||
}
|
||||
|
||||
/** Registered account names according to EasyAuth. */
|
||||
export async function easyauthList(): Promise<string[]> {
|
||||
if (authProvider() === "serverlogin") {
|
||||
// Confirm the server is reachable before reconciling registration windows.
|
||||
await execChecked("auth count");
|
||||
const file = path.join(env.mcDataDir, "world", "serverlogin", "users.json");
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return parseServerLoginAccounts(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
const out = stripColors(await rcon().exec("auth list"));
|
||||
if (/unknown|incorrect|expected/i.test(out)) throw new Error("Authentication provider did not return an account list");
|
||||
// Output is a list of registered usernames; extract all name-like tokens
|
||||
// after a colon if present, otherwise from the whole string.
|
||||
const colon = out.indexOf(":");
|
||||
@@ -404,6 +425,7 @@ export async function easyauthRemove(name: string): Promise<string> {
|
||||
|
||||
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
if (authProvider() === "serverlogin") throw new Error("Use Force re-register, then let the player choose a new password in-game");
|
||||
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
|
||||
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export function validateProfile(
|
||||
),
|
||||
pvp: bool(v.pvp, "PVP"),
|
||||
hardcore: bool(v.hardcore, "hardcore"),
|
||||
route: choice(v.route, ["proxy", "direct"], "connection mode"),
|
||||
route: choice(v.route, ["proxy", "direct", "internal"], "connection mode"),
|
||||
port: integer(v.port, "Game port", 1024, 65535),
|
||||
hostname: string(v.hostname, "hostname", 253).toLowerCase(),
|
||||
mods: string(v.mods, "mods", 4000),
|
||||
|
||||
@@ -273,7 +273,7 @@ export async function deployProfile(id: string, progress: (s: string) => void) {
|
||||
"Container started but Minecraft readiness is unverified; check its console",
|
||||
);
|
||||
const routing = await refreshAppleGateway();
|
||||
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : `Connect on port ${p.port}.`)}`;
|
||||
return `${p.name} is healthy. ${routing || (p.route === "proxy" ? "Apply proxy routing to make it available through the network." : p.route === "internal" ? "Available to the existing gateway on the private Docker network." : `Connect on port ${p.port}.`)}`;
|
||||
}
|
||||
/** Native VM addresses are ephemeral. Refresh only a gateway the user has started. */
|
||||
async function refreshAppleGateway() {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
vi.mock("../src/server/env", () => ({ env: { mcDataDir: "" } }));
|
||||
vi.mock("../src/server/rcon", () => ({ rcon: () => ({ exec: vi.fn() }), stripColors: (s: string) => s, RconError: class extends Error {} }));
|
||||
import { env } from "../src/server/env";
|
||||
import { authProvider, registrationCommand, parseServerLoginAccounts, easyauthSetPassword } from "../src/server/mc";
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "craftdeck-auth-")); env.mcDataDir = dir; });
|
||||
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
it("uses the Forge registration flow and never sends an unsupported password reset command", async () => {
|
||||
expect(authProvider()).toBe("easyauth");
|
||||
fs.mkdirSync(path.join(dir, "config")); fs.writeFileSync(path.join(dir, "config/serverlogin-common.toml"), "");
|
||||
expect(registrationCommand()).toBe("/login <password> <password>");
|
||||
await expect(easyauthSetPassword("aryan", "test-only")).rejects.toThrow("Force re-register");
|
||||
});
|
||||
it("returns names only from the documented account database and rejects unknown formats", () => {
|
||||
expect(parseServerLoginAccounts(JSON.stringify({ formatVersion: 1, users: { aryan: { lastKnownName: "aryan", salt: "private", hash: "private" } } }))).toEqual(["aryan"]);
|
||||
expect(() => parseServerLoginAccounts('{"formatVersion":2,"users":{}}')).toThrow();
|
||||
expect(() => parseServerLoginAccounts('{"formatVersion":1,"users":[]}')).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../src/server/env", () => ({ env: { mcContainer: "mc" } }));
|
||||
vi.mock("../src/server/network-config", () => ({
|
||||
readNetwork: () => ({ profiles: [{ id: "horror", name: "Horror" }] }),
|
||||
profileById: (config: { profiles: Array<{ id: string }> }, id: string) => {
|
||||
const p = config.profiles.find(p => p.id === id);
|
||||
if (!p) throw new Error("Unknown server");
|
||||
return p;
|
||||
},
|
||||
}));
|
||||
import { createDemuxer } from "../src/server/docker";
|
||||
import { consoleTarget } from "../src/server/console-target";
|
||||
const frame = (payload: Buffer) => {
|
||||
const header = Buffer.alloc(8); header[0] = 1; header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
describe("console transport", () => {
|
||||
it("keeps UTF-8 characters split across Docker frames and flushes output without a newline", () => {
|
||||
const lines: string[] = []; const demux = createDemuxer(s => lines.push(s));
|
||||
const bytes = Buffer.from("hello 🌫️\nDone");
|
||||
const data = Buffer.concat([frame(bytes.subarray(0, 8)), frame(bytes.subarray(8))]);
|
||||
for (const byte of data) demux(Buffer.from([byte]));
|
||||
demux.end();
|
||||
expect(lines).toEqual(["hello 🌫️", "Done"]);
|
||||
});
|
||||
it("shows short raw TTY output", () => {
|
||||
const lines: string[] = []; const demux = createDemuxer(s => lines.push(s));
|
||||
demux(Buffer.from("OK")); demux.end(); expect(lines).toEqual(["OK"]);
|
||||
});
|
||||
it("allows only primary or registered profile IDs", () => {
|
||||
expect(consoleTarget(null)).toBe("mc");
|
||||
expect(consoleTarget("horror")).toBe("craftdeck-horror");
|
||||
expect(() => consoleTarget("komodo-core")).toThrow();
|
||||
expect(() => consoleTarget("../escape")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,13 @@ describe("isolated server profiles", () => {
|
||||
MODRINTH_VERSION: "version123",
|
||||
});
|
||||
});
|
||||
it("keeps modded internal servers authenticated without publishing any ports", () => {
|
||||
const p = make({ loader: "FORGE", version: "1.20.1", java: "17", route: "internal" });
|
||||
const c = renderProfileCompose(p, { hostDataDir: "/data/mc", network: "stack" }, "secret", "");
|
||||
expect(c.services.server.ports).toEqual([]);
|
||||
expect(c.services.server.environment.ONLINE_MODE).toBe("TRUE");
|
||||
expect(c.services.server.environment.ENABLE_WHITELIST).toBe("TRUE");
|
||||
});
|
||||
it("rejects invalid numbers, types, environment newlines and URL schemes", () => {
|
||||
for (const update of [
|
||||
{ memory: 0 },
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Play From the Fog Reimagined
|
||||
|
||||
[Open the pack website](https://packs.aryan-singh.dev).
|
||||
|
||||
**Server:** `minecraft.ayush-jain.dev`
|
||||
**Pack:** From the Fog Reimagined **6.3.2** by SoumXD
|
||||
**Minecraft:** 1.20.1 · Forge 47.4.10 · Java 17
|
||||
|
||||
## Install once
|
||||
|
||||
1. Open your launcher’s modpack installer. For offline-name accounts, [SKlauncher 4.0](https://docs.skmedix.pl/4.0/) supports CurseForge packs and automatic Java setup. Its 4.0 release is currently beta. If you use Microsoft sign-in, the [CurseForge app](https://www.curseforge.com/download/app) can install the pack directly.
|
||||
2. Search **From the Fog Reimagined** by **SoumXD**, select **6.3.2**, and install the whole pack. [Exact official client release](https://www.curseforge.com/minecraft/modpacks/from-the-fog-reimagined/files/9191040).
|
||||
3. Launch that pack. Give the game 6 GB of memory if your computer has room; the pack author lists 4 GB as the client minimum. Shaders are optional—turn them off if performance is poor.
|
||||
4. Multiplayer → Add Server → `minecraft.ayush-jain.dev`.
|
||||
|
||||
Use the exact username the admin approved, including capitalization. Start with `aryan` or `jainster` as appropriate. Ask the admin to approve any additional name in CraftDeck → Access & Auth immediately before your first join.
|
||||
|
||||
On first join, follow the server prompt: `/login YOUR_PASSWORD YOUR_PASSWORD`. On later joins: `/login YOUR_PASSWORD`. Choose your own password in the game; do not send it to the admin or reuse an important account password. If the registration window expires, the admin can reopen it in CraftDeck.
|
||||
|
||||
## If joining fails
|
||||
|
||||
- **Not whitelisted:** check the exact username and ask the admin to reopen approval.
|
||||
- **Missing/incompatible mods:** launch the complete **6.3.2** pack, rather than vanilla or a standalone From the Fog datapack. Both sides must use 1.20.1 Forge.
|
||||
- **Out of memory / slow:** allocate memory in this pack’s launcher settings and disable shaders first.
|
||||
- **Cannot connect:** ask the admin to check the Minecraft container and TCPShield route. The CraftDeck admin address is private and is not the game server address.
|
||||
|
||||
The server uses the pack author’s matching server pack, plus a server-only login mod. Friends do not need to copy server files or install that login mod. Download pack content from the author through your launcher; no separate mod ZIP collection or hosted mirror is needed.
|
||||
|
||||
This is the official Reimagined pack baseline. An exact CaseOh-specific mod/shader list has not been verified.
|
||||
Reference in new issue
Block a user