From 856c499a76e3ea9b4f22e4ec740d5123cbb3c80c Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 16:19:35 +0100 Subject: [PATCH 1/6] Add World Type and the other first-start settings Minecraft reads level-type, hardcore, generator-settings and (from 1.19.3) initial-enabled-packs/initial-disabled-packs only when it generates a world, and a server only writes them to server.properties on its first start; 1.17 never writes most of them at all. So none of them could be set through Craftbox before the one start that uses them. New servers now get them up front, with the defaults each version writes itself, taken from real 1.12.2, 1.16.5, 1.17.1, 1.18.2, 1.19.2, 1.19.3 and 26.3 servers. The create page and Settings gain World Type, and their world options sit under a separator, two to a row: Game Mode and Difficulty, then World Type and World Seed. Minecraft 1.19 renamed the world types and reads none of the old names it doesn't happen to share: on 26.3 `largeBiomes` or `default` generates a normal world, and on 1.18.2 `minecraft:flat` does the same. So each version gets its own list, a value is stored in the spelling the server's version reads, and a version change in Settings translates it. A value Craftbox doesn't know, such as a modpack's own world type, is kept rather than saved over, and creating from a modpack offers "Modpack default" so the pack's stays unless another is picked. The API takes `levelType` on create, both modpack creates and edit, the server record mirrors it from server.properties, and templates carry it. The Properties page lists the new settings and matches enum values case-insensitively, since 1.12 writes `DEFAULT`. --- docs/API.md | 25 ++++++++++---- public/js/create.js | 62 +++++++++++++++++++++++++++++++++ src/mc/modpackInstaller.js | 17 ++++++--- src/mc/propertyMeta.js | 22 ++++++------ src/mc/serverProperties.js | 21 ++++++++++-- src/mc/syncServerConfig.js | 6 ++++ src/mc/worldTypes.js | 63 ++++++++++++++++++++++++++++++++++ src/routes/api-v1/servers.js | 53 +++++++++++++++++++++++++--- src/routes/api-v1/templates.js | 1 + src/routes/servers.js | 16 ++++++++- src/utils/mcVersion.js | 33 +++++++++++++++++- views/servers/create.ejs | 22 ++++++++++-- views/servers/edit.ejs | 23 +++++++++++-- views/servers/properties.ejs | 7 ++-- 14 files changed, 331 insertions(+), 40 deletions(-) create mode 100644 src/mc/worldTypes.js diff --git a/docs/API.md b/docs/API.md index 140c8e6..718ace4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -92,7 +92,7 @@ Allowed lifecycle actions: **start** from `stopped`/`crashed`; **stop** from `ru ## Servers -The server object returned by these endpoints contains the full configuration (name, type, version, port, memory, JVM args, gamemode, difficulty, seed, flags, `group`, timestamps, `state`, `exitCode`, `crashReason`, `javaMajor` — the Java runtime requirement recorded from Mojang metadata at jar download time, null when unknown, …). The on-disk `directory` field is stripped from responses. +The server object returned by these endpoints contains the full configuration (name, type, version, port, memory, JVM args, gamemode, difficulty, `levelType` (World Type, mirrored from `server.properties`), seed, flags, `group`, timestamps, `state`, `exitCode`, `crashReason`, `javaMajor` — the Java runtime requirement recorded from Mojang metadata at jar download time, null when unknown, …). The on-disk `directory` field is stripped from responses. ### Read @@ -110,7 +110,7 @@ The server object returned by these endpoints contains the full configuration (n | Method | Path | Description | |---|---|---| -| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background | +| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background | | POST | `/servers/from-modpack` | Create from a Modrinth modpack — see [Modrinth](#modrinth) | | POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack` file — see [Modrinth](#modrinth) | | POST | `/servers/:id/duplicate` | Clone a server. Body: `{name, port, includeWorld?, stopFirst?, startAfter?}`. `409` if running and `stopFirst` is not set. Returns `201` | @@ -145,7 +145,7 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be | Method | Path | Description | |---|---|---| -| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool}`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead | +| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool}`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead | | POST | `/servers/:id/group` | Assign the dashboard group. Body: `{group}` (empty/null to ungroup). Returns `{"group": ..., "color": ...}` — `color` is the group's folder color (null when ungrouped) | | POST | `/servers/:id/autorestart` | Body: `{enabled: bool}`. Returns `{"autoRestart": bool}` | | POST | `/servers/:id/autostart` | Body: `{enabled: bool}`. Returns `{"autoStart": bool}` | @@ -155,6 +155,19 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be | POST | `/servers/:id/properties` | Update `server.properties`. Body: an object keyed by property name, plus an optional `backup` flag (reserved — never written as a property). A partial update: properties left out keep their current value, and keys not already in the file are ignored. Toggles take `true`/`false` (boolean or string) — anything else is `400`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead of `{"success": true}` | | POST | `/servers/:id/edit-file` | Save a text file inside the server directory. Body: `{filePath, content}`. `403` on path traversal, `400` if the target is not text (see [Text vs binary](#files)) | +### World Type + +`levelType` is `server.properties`' `level-type`, and like the seed it only applies when a new world is generated. Minecraft 1.19 renamed the world types and reads none of the old names it doesn't happen to share, so each version takes its own: + +| Minecraft | Values | +|---|---| +| 1.19 and later (including 26.x) | `minecraft:normal`, `minecraft:flat`, `minecraft:large_biomes`, `minecraft:amplified`, `minecraft:single_biome_surface` | +| Before 1.19 | `default`, `flat`, `largeBiomes`, `amplified` | + +Either spelling is accepted and stored in the one the server's version reads, so `flat` on a 26.x server is saved as `minecraft:flat`. A type the version has no equivalent of (Single Biome before 1.19) is `400`. Left out on create, it defaults to the version's normal world; left out on `/edit`, it keeps its value, except that a version change translates it into the new version's spelling. `/edit` also accepts the value already in the file as it stands, so a modpack's own world type survives a settings save. + +New servers' `server.properties` carries every setting Minecraft only reads when it generates a world, so they can be set before the first start: `hardcore`, `level-type`, `generator-settings`, and from 1.19.3 `initial-enabled-packs` / `initial-disabled-packs`, each with the default that version would write itself. + ### Files Paths are relative to the server directory and are resolved against it with symlinks fully resolved — anything landing outside returns `403 {"error": "Access denied."}`. @@ -383,10 +396,10 @@ Two quirks of Modrinth's search are worked around inside the proxy, so these end | Method | Path | Description | |---|---|---| -| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above | +| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above | | POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack`. Multipart, file field `mrpack`, max 2 GiB, plus the same base fields as text fields. The pack is parsed and the loader resolved **before** any server record is created, so malformed or Quilt packs fail with a clean `400`. Returns `201` + background install. Also accepts [chunked uploads](#chunked-uploads-dgup) at `/servers/from-mrpack/upload/*` | -The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed, when one is given — a blank seed keeps the pack's), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import. +The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed and `levelType`, when given — left blank, the pack's are kept), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import. ### Install a mod or plugin into an existing server @@ -405,7 +418,7 @@ The background install downloads the pack's files (SHA-512 verified; download ho | POST | `/templates` | Create from an existing server. Body: `{serverId, name, stopFirst?, startAfter?}`. Returns `201` | | DELETE | `/templates/:id` | Delete a template | -Templates capture reusable configuration (type, version, memory, JVM args, gamemode, difficulty, port, behavior flags) — not world data, files, or dashboard groups. +Templates capture reusable configuration (type, version, memory, JVM args, gamemode, difficulty, world type, port, behavior flags) — not world data, files, or dashboard groups. ## API keys diff --git a/public/js/create.js b/public/js/create.js index 98d8eeb..ec9dbf1 100644 --- a/public/js/create.js +++ b/public/js/create.js @@ -49,6 +49,56 @@ const versionPicker = CraftboxVersionPicker({ function setVersionField(id) { versionHidden.value = id || ''; versionDisplay.value = id || ''; + if (!inModpackSource()) renderWorldTypes(selectedType === 'custom' ? '' : id); +} + +// ── World Type ── +// Minecraft 1.19 renamed the world types (default → minecraft:normal) and +// reads no old name it doesn't happen to share, so the list follows the chosen +// version. A modpack keeps its own world type unless one is picked. +const levelTypeSelect = document.getElementById('levelType'); +const WORLD_TYPES = JSON.parse(levelTypeSelect.dataset.worldTypes); +const MODPACK_WORLD_TYPE = { value: '', label: 'Modpack default' }; + +function inModpackSource() { + return createMode === 'modpack' || selectedSource === 'modpack'; +} + +// Mirrors isAtLeast(version, '1.19', '22w11a') in src/utils/mcVersion.js. A +// blank or unrecognised version (custom jars) counts as current. +function usesWorldPresets(version) { + const v = String(version || '').trim(); + const snapshot = /^(\d{2})w(\d{2})/.exec(v); + if (snapshot) return Number(snapshot[1]) * 100 + Number(snapshot[2]) >= 2211; + const release = /^(\d+)\.(\d+)/.exec(v); + if (!release) return true; + return Number(release[1]) > 1 || Number(release[2]) >= 19; +} + +// Rebuild the options for `version` (or the modpack list), keeping the +// current choice where the new list has the same world type under another +// name. `select` picks by label instead. +function renderWorldTypes(version, { modpack = false, select } = {}) { + const list = modpack + ? [MODPACK_WORLD_TYPE].concat(WORLD_TYPES.presets) + : (usesWorldPresets(version) ? WORLD_TYPES.presets : WORLD_TYPES.legacy); + const wanted = select !== undefined ? select : (levelTypeSelect.selectedOptions[0] || {}).textContent; + levelTypeSelect.innerHTML = ''; + list.forEach(function (t) { + const opt = document.createElement('option'); + opt.value = t.value; + opt.textContent = t.label; + levelTypeSelect.appendChild(opt); + }); + const keep = list.find(function (t) { return t.label === wanted; }); + levelTypeSelect.value = keep ? keep.value : list[0].value; +} + +// A saved world type's label, whichever list it came from +function worldTypeLabel(value) { + const all = WORLD_TYPES.presets.concat(WORLD_TYPES.legacy); + const match = all.find(function (t) { return t.value.toLowerCase() === String(value || '').toLowerCase(); }); + return match ? match.label : undefined; } function openVersionPicker() { @@ -194,6 +244,7 @@ async function selectType(typeId) { customUrlGroup.classList.remove('d-none'); versionDisplay.removeAttribute('required'); setCustomNoticeVisible(true); + renderWorldTypes(''); centerLoneRowItems(form); } else { versionGroup.classList.remove('d-none'); @@ -340,6 +391,9 @@ templateSelect.addEventListener('change', async () => { if (t.port) document.getElementById('port').value = t.port; if (t.gamemode) document.getElementById('gamemode').value = t.gamemode; if (t.difficulty) document.getElementById('difficulty').value = t.difficulty; + if (t.levelType && worldTypeLabel(t.levelType)) { + renderWorldTypes(t.serverType === 'custom' ? '' : versionHidden.value, { select: worldTypeLabel(t.levelType) }); + } // Advanced options if (t.memory && t.memory !== 2048) { @@ -380,6 +434,7 @@ function collectBaseFields() { javaArgs: document.getElementById('javaArgs').value, gamemode: document.getElementById('gamemode').value, difficulty: document.getElementById('difficulty').value, + levelType: levelTypeSelect.value, seed: document.getElementById('seed').value, group: document.getElementById('group').value }; @@ -438,6 +493,7 @@ async function enterModpackMode() { createSourceGroup.classList.add('d-none'); hideTypeAndVersionPickers(); modpackSummary.classList.remove('d-none'); + renderWorldTypes(null, { modpack: true, select: MODPACK_WORLD_TYPE.label }); suggestModpackMemory(); validateCreateForm(); @@ -526,6 +582,7 @@ function applySource(source) { versionDisplay.removeAttribute('required'); customUrlGroup.classList.add('d-none'); setCustomNoticeVisible(false); + renderWorldTypes(null, { modpack: true, select: MODPACK_WORLD_TYPE.label }); suggestModpackMemory(); } else { mrpackFileInput.removeAttribute('required'); @@ -537,6 +594,11 @@ function applySource(source) { versionGroup.classList.remove('d-none'); versionDisplay.setAttribute('required', ''); } + // Back from the modpack list: "Modpack default" has no counterpart, + // so this falls to the version's default unless a type was picked + if (levelTypeSelect.querySelector('option[value=""]')) { + renderWorldTypes(selectedType === 'custom' ? '' : versionHidden.value); + } revertModpackMemory(); } validateCreateForm(); diff --git a/src/mc/modpackInstaller.js b/src/mc/modpackInstaller.js index af6e091..c09b0d8 100644 --- a/src/mc/modpackInstaller.js +++ b/src/mc/modpackInstaller.js @@ -11,6 +11,7 @@ const path = require('path'); const StreamZip = require('node-stream-zip'); const { downloadServerJar } = require('./downloader'); const { writeServerProperties, writeEula, parseServerProperties } = require('./serverProperties'); +const { worldTypeFor } = require('./worldTypes'); const { downloadToFile, assertWhitelistedUrl } = require('../utils/httpDownload'); const { setServerIcon } = require('../utils/serverIcon'); const { DISABLED_SUFFIX } = require('../utils/modEnvironment'); @@ -163,7 +164,7 @@ async function runPool(items, concurrency, worker) { * @param {string} opts.serverId * @param {string} opts.serverDir - Absolute path * @param {object} opts.mrpack - { url, sha512 } (fetch it) or { localPath } (already on disk) - * @param {object} opts.baseConfig - { port, gamemode, difficulty, seed } for server.properties + * @param {object} opts.baseConfig - { port, gamemode, difficulty, levelType, seed } for server.properties * @param {string|null} [opts.iconUrl] - Modrinth CDN icon to use as the server icon (non-fatal) * @param {function} [opts.onProgress] - (phase, done, total) * @returns {Promise<{serverType, mcVersion, loaderBuild, build, filesInstalled, modsInstalled, clientOnlyMods, manifestName, manifestVersionId, warnings}>} @@ -349,9 +350,14 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl // ── Phase 6: finalize ── emit('finalize'); // After overrides on purpose: Craftbox-managed values (port, gamemode, - // difficulty, seed) must win over any server.properties the pack ships, - // while the rest of the pack's file is kept. A blank seed is no choice - // at all, so it leaves a seed the pack ships in place. + // difficulty, seed, world type) must win over any server.properties the + // pack ships, while the rest of the pack's file is kept. A blank seed or + // world type is no choice at all, so it leaves the pack's in place; + // anything the file still lacks gets this version's default. + const levelType = baseConfig.levelType ? worldTypeFor(baseConfig.levelType, mcVersion) : null; + if (baseConfig.levelType && !levelType) { + warnings.push(`World type ${baseConfig.levelType} isn't available for Minecraft ${mcVersion}, so the modpack's own was kept.`); + } const packProps = parseServerProperties(serverDir); const overruled = Object.keys(PINNED_PROPERTIES) .filter(key => packProps[key] && packProps[key] !== String(PINNED_PROPERTIES[key])); @@ -364,8 +370,9 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl serverPort: baseConfig.port, gamemode: baseConfig.gamemode, difficulty: baseConfig.difficulty, + levelType: levelType || undefined, levelSeed: baseConfig.seed || undefined - }, { mergeExisting: true }); + }, { mergeExisting: true, version: mcVersion }); writeEula(serverDir); if (iconUrl) { diff --git a/src/mc/propertyMeta.js b/src/mc/propertyMeta.js index 7d5cd9e..e162532 100644 --- a/src/mc/propertyMeta.js +++ b/src/mc/propertyMeta.js @@ -1,6 +1,8 @@ // Metadata for known Minecraft server.properties keys // Used by the properties editor to render appropriate form fields +const { WORLD_PRESETS } = require('./worldTypes'); + const PROPERTY_META = { // --- Gameplay --- 'gamemode': { @@ -21,7 +23,7 @@ const PROPERTY_META = { { value: 'hard', label: 'Hard' } ], default: 'easy' }, - 'hardcore': { type: 'boolean', label: 'Hardcore', group: 'gameplay', default: 'false', description: 'One life only — players cannot respawn' }, + 'hardcore': { type: 'boolean', label: 'Hardcore', group: 'gameplay', default: 'false', description: 'One life only: players cannot respawn. Only applies when generating a new world' }, 'pvp': { type: 'boolean', label: 'PvP', group: 'gameplay', default: 'true' }, 'max-players': { type: 'number', label: 'Max Players', group: 'gameplay', min: 0, max: 999, default: '20' }, 'force-gamemode': { type: 'boolean', label: 'Force Game Mode', group: 'gameplay', default: 'false', description: 'Force players to join in the default game mode' }, @@ -32,24 +34,22 @@ const PROPERTY_META = { // --- World --- 'level-name': { type: 'string', label: 'World Name', group: 'world', default: 'world' }, 'level-seed': { type: 'string', label: 'World Seed', group: 'world', default: '', description: 'Only applies when generating a new world' }, - // Unescaped, as parseServerProperties returns them. Minecraft writes - // `minecraft\:normal`, but reads a bare colon in a value just the same. + // The 1.19+ presets; the properties page swaps in the list for the + // server's own version (worldTypes.js). 'level-type': { type: 'enum', label: 'World Type', group: 'world', - options: [ - { value: 'minecraft:normal', label: 'Normal' }, - { value: 'minecraft:flat', label: 'Flat' }, - { value: 'minecraft:large_biomes', label: 'Large Biomes' }, - { value: 'minecraft:amplified', label: 'Amplified' }, - { value: 'minecraft:single_biome_surface', label: 'Single Biome' } - ], default: 'minecraft:normal' + options: WORLD_PRESETS, default: WORLD_PRESETS[0].value, + description: 'Only applies when generating a new world' }, - 'generate-structures': { type: 'boolean', label: 'Generate Structures', group: 'world', default: 'true' }, + 'generator-settings': { type: 'string', label: 'Generator Settings', group: 'world', default: '', description: 'Layers for a flat world, as JSON. Only applies when generating a new world' }, + 'generate-structures': { type: 'boolean', label: 'Generate Structures', group: 'world', default: 'true', description: 'Only applies to newly generated chunks' }, 'max-world-size': { type: 'number', label: 'Max World Size', group: 'world', min: 1, max: 29999984, default: '29999984', description: 'World border radius in blocks' }, 'spawn-animals': { type: 'boolean', label: 'Spawn Animals', group: 'world', default: 'true' }, 'spawn-monsters': { type: 'boolean', label: 'Spawn Monsters', group: 'world', default: 'true' }, 'spawn-npcs': { type: 'boolean', label: 'Spawn Villagers', group: 'world', default: 'true' }, 'allow-nether': { type: 'boolean', label: 'Allow Nether', group: 'world', default: 'true' }, + 'initial-enabled-packs': { type: 'string', label: 'Enabled Datapacks', group: 'world', default: 'vanilla', description: 'Comma-separated. Only applies when generating a new world' }, + 'initial-disabled-packs': { type: 'string', label: 'Disabled Datapacks', group: 'world', default: '', description: 'Comma-separated. Only applies when generating a new world' }, // --- Network --- 'server-port': { type: 'number', label: 'Server Port', group: 'network', min: 1024, max: 65535, default: '25565' }, diff --git a/src/mc/serverProperties.js b/src/mc/serverProperties.js index f803408..25cb256 100644 --- a/src/mc/serverProperties.js +++ b/src/mc/serverProperties.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const { firstStartProperties } = require('./worldTypes'); const DEFAULT_PROPERTIES = { 'server-port': 25565, @@ -33,6 +34,17 @@ const DEFAULT_PROPERTIES = { 'entity-broadcast-range-percentage': 100 }; +// Craftbox's defaults for a server on `version`, with the settings Minecraft +// only reads when it generates a world placed after the seed. +function defaultsFor(version) { + const props = {}; + for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) { + props[key] = value; + if (key === 'level-seed') Object.assign(props, firstStartProperties(version)); + } + return props; +} + // Map writeServerProperties overrides (camelCase keys, or raw kebab-case keys) // to server.properties keys. function resolveOverrides(overrides) { @@ -44,6 +56,7 @@ function resolveOverrides(overrides) { if (overrides.difficulty !== undefined) props['difficulty'] = overrides.difficulty; if (overrides.viewDistance !== undefined) props['view-distance'] = overrides.viewDistance; if (overrides.levelSeed !== undefined) props['level-seed'] = overrides.levelSeed; + if (overrides.levelType !== undefined) props['level-type'] = overrides.levelType; // Allow raw property overrides (`level-seed`, `rcon.password`) Object.entries(overrides).forEach(([key, value]) => { @@ -60,8 +73,10 @@ function resolveOverrides(overrides) { * @param {boolean} [options.mergeExisting] - Keep a server.properties already in * the directory (e.g. one a modpack shipped): only the overrides are forced, * and defaults fill in just the keys it leaves out. + * @param {string} [options.version] - The server's Minecraft version, which + * decides the world-generation defaults (see worldTypes.js) */ -function writeServerProperties(serverDir, overrides = {}, { mergeExisting = false } = {}) { +function writeServerProperties(serverDir, overrides = {}, { mergeExisting = false, version = '' } = {}) { const managed = resolveOverrides(overrides); if (mergeExisting && fs.existsSync(path.join(serverDir, 'server.properties'))) { @@ -70,14 +85,14 @@ function writeServerProperties(serverDir, overrides = {}, { mergeExisting = fals // unicode escapes Minecraft writes for a MOTD's colour codes. const existing = parseServerProperties(serverDir); const missingDefaults = {}; - for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) { + for (const [key, value] of Object.entries(defaultsFor(version))) { if (!(key in existing)) missingDefaults[key] = value; } updateServerProperties(serverDir, { ...missingDefaults, ...managed }); return; } - const props = { ...DEFAULT_PROPERTIES, ...managed }; + const props = { ...defaultsFor(version), ...managed }; const lines = ['#Minecraft server properties', `#Generated by Craftbox on ${new Date().toISOString()}`]; for (const [key, value] of Object.entries(props)) { diff --git a/src/mc/syncServerConfig.js b/src/mc/syncServerConfig.js index 872a06c..a3b1ad0 100644 --- a/src/mc/syncServerConfig.js +++ b/src/mc/syncServerConfig.js @@ -49,6 +49,12 @@ async function syncServerConfig(serverId) { changed = true; } + if (props['level-type'] && props['level-type'] !== server.levelType) { + log('info', `[${server.name}] Config sync: world type ${server.levelType || '(none)'} → ${props['level-type']}`); + server.levelType = props['level-type']; + changed = true; + } + // Sync EULA acceptance from eula.txt const eulaPath = path.join(serverDir, 'eula.txt'); if (fs.existsSync(eulaPath)) { diff --git a/src/mc/worldTypes.js b/src/mc/worldTypes.js new file mode 100644 index 0000000..d6332e7 --- /dev/null +++ b/src/mc/worldTypes.js @@ -0,0 +1,63 @@ +// World Type (server.properties `level-type`) and the other settings Minecraft +// only reads when it generates a new world. Values are unescaped, as +// parseServerProperties returns them; Minecraft reads a bare colon the same. +const { isAtLeast } = require('../utils/mcVersion'); + +// Minecraft 1.19 replaced world types with namespaced world presets. It reads +// none of the old names it doesn't happen to share: `largeBiomes` or `default` +// on a 1.19+ server generates a normal world, and `minecraft:flat` on 1.18 does +// the same. +const WORLD_PRESETS = [ + { value: 'minecraft:normal', label: 'Normal' }, + { value: 'minecraft:flat', label: 'Flat' }, + { value: 'minecraft:large_biomes', label: 'Large Biomes' }, + { value: 'minecraft:amplified', label: 'Amplified' }, + { value: 'minecraft:single_biome_surface', label: 'Single Biome' } +]; +const LEGACY_WORLD_TYPES = [ + { value: 'default', label: 'Normal' }, + { value: 'flat', label: 'Flat' }, + { value: 'largeBiomes', label: 'Large Biomes' }, + { value: 'amplified', label: 'Amplified' } +]; + +const usesPresets = (version) => isAtLeast(version, '1.19', '22w11a'); + +/** The World Type options a server on `version` understands, default first. */ +function worldTypesFor(version) { + return usesPresets(version) ? WORLD_PRESETS : LEGACY_WORLD_TYPES; +} + +/** + * A world type in the form `version` reads, matched by what it is rather than + * how it's spelt (`flat` ↔ `minecraft:flat`). Null for a value that is no world + * type Craftbox knows, or one the version has no equivalent of (Single Biome + * before 1.19). + */ +function worldTypeFor(value, version) { + const wanted = String(value || '').trim().toLowerCase(); + const known = [...WORLD_PRESETS, ...LEGACY_WORLD_TYPES].find(t => t.value.toLowerCase() === wanted); + if (!known) return null; + return worldTypesFor(version).find(t => t.label === known.label)?.value || null; +} + +/** + * Settings Minecraft only reads when it generates a new world, with the + * defaults `version` writes itself. Written into a new server's + * server.properties so they can be set before the first start, since some + * versions (1.17) never write them at all. + */ +function firstStartProperties(version) { + const props = { + 'hardcore': false, + 'level-type': worldTypesFor(version)[0].value, + 'generator-settings': isAtLeast(version, '1.18', '21w37a') ? '{}' : '' + }; + if (isAtLeast(version, '1.19.3', '22w42a')) { + props['initial-enabled-packs'] = 'vanilla'; + props['initial-disabled-packs'] = ''; + } + return props; +} + +module.exports = { WORLD_PRESETS, LEGACY_WORLD_TYPES, worldTypesFor, worldTypeFor, firstStartProperties }; diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index d60b3c9..e447953 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -25,6 +25,7 @@ const { clearStatsHistory, getStatsHistory } = require('../../utils/statsHistory const { setServerIcon, resetServerIcon, removeServerIcon, getIconPath, copyDefaultIcon } = require('../../utils/serverIcon'); const { writeServerProperties, writeEula, parseServerProperties, updateServerProperties } = require('../../mc/serverProperties'); const { PROPERTY_META } = require('../../mc/propertyMeta'); +const { worldTypeFor, worldTypesFor } = require('../../mc/worldTypes'); const { getContentType } = require('../../utils/contentType'); const { copyModEnvMap, setModEnvMap, getModEnvMap } = require('../../utils/modEnvironment'); const { isZipFile, cleanupTempFiles } = require('../../utils/uploadSafety'); @@ -1032,7 +1033,7 @@ router.post('/servers/:id/motd', async (req, res) => { // from-modpack, from-mrpack) so the rules cannot drift apart. // Returns { error } on failure, otherwise the normalized values. function validateBaseServerFields(body) { - const { name, port, memory, javaArgs, eula, gamemode, difficulty, seed, group } = body || {}; + const { name, port, memory, javaArgs, eula, gamemode, difficulty, levelType, seed, group } = body || {}; if (!name || !port || !memory) { return { error: 'All required fields must be filled.' }; @@ -1069,6 +1070,13 @@ function validateBaseServerFields(body) { return { error: GROUP_NAME_ERROR }; } + // Blank means the default (or, for a modpack, the pack's own). Which + // spelling a version reads is settled once the version is known. + const levelTypeStr = String(levelType || '').trim(); + if (levelTypeStr && !worldTypeFor(levelTypeStr, '')) { + return { error: `Unknown world type: ${levelTypeStr}.` }; + } + return { trimmedName, portNum, @@ -1076,6 +1084,7 @@ function validateBaseServerFields(body) { safeJavaArgs: String(javaArgs || '').trim(), gamemodeStr: validGamemodes.includes(gamemode) ? gamemode : 'survival', difficultyStr: validDifficulties.includes(difficulty) ? difficulty : 'easy', + levelTypeStr, seedStr: String(seed || '').trim(), group: groupResult.value }; @@ -1121,6 +1130,14 @@ router.post('/servers', async (req, res) => { } } + // In the spelling this version reads; the default when none was asked for + const levelTypeStr = base.levelTypeStr + ? worldTypeFor(base.levelTypeStr, versionStr) + : worldTypesFor(versionStr)[0].value; + if (!levelTypeStr) { + return res.status(400).json({ error: `World type ${base.levelTypeStr} isn't available for Minecraft ${versionStr}.` }); + } + const id = uuidv4(); const serverDir = path.join(SERVERS_DIR, id); const initiatedBy = req.user.username; @@ -1152,6 +1169,7 @@ router.post('/servers', async (req, res) => { version: versionStr, gamemode: gamemodeStr, difficulty: difficultyStr, + levelType: levelTypeStr, seed: seedStr, customJarUrl: type === 'custom' ? customJarUrl.trim() : null, jarFile: 'server.jar', @@ -1183,8 +1201,9 @@ router.post('/servers', async (req, res) => { serverPort: portNum, gamemode: gamemodeStr, difficulty: difficultyStr, + levelType: levelTypeStr, levelSeed: seedStr - }); + }, { version: versionStr }); writeEula(serverDir); const fresh = await serversDb.get(`server_${id}`); @@ -1295,6 +1314,9 @@ function buildModpackServerRecord({ id, base, serverType, version, modpackMeta } version, gamemode: base.gamemodeStr, difficulty: base.difficultyStr, + // Provisional: the install settles it against the pack, then the + // record is synced from server.properties + levelType: base.levelTypeStr || null, seed: base.seedStr, customJarUrl: null, jarFile: 'server.jar', @@ -1329,6 +1351,7 @@ async function provisionModpackServer({ req, id, serverDir, name, base, mrpack, port: base.portNum, gamemode: base.gamemodeStr, difficulty: base.difficultyStr, + levelType: base.levelTypeStr, seed: base.seedStr }, iconUrl, @@ -1361,8 +1384,9 @@ async function provisionModpackServer({ req, id, serverDir, name, base, mrpack, if (!fresh.modpack.versionNumber) fresh.modpack.versionNumber = result.manifestVersionId; } await serversDb.set(`server_${id}`, fresh); - // A seed the pack ships survives a blank one — mirror it so the - // settings page shows it (and a save there doesn't clear it). + // A seed or world type the pack ships survives a blank one; mirror + // them so the settings page shows them (and a save there doesn't + // clear them). await syncServerConfig(id); } @@ -2261,6 +2285,25 @@ router.post('/servers/:id/edit', async (req, res) => { } } + // World Type in the spelling the (possibly new) version reads. A known type + // is translated, the value already in the file (a modpack's own type) is + // kept as it is, and anything else is refused. When none is sent, a version + // change still translates the file's value, so crossing 1.19 keeps it. + const targetVersion = type !== 'custom' && newVersion ? newVersion : server.version; + const fileLevelType = parseServerProperties(path.join(SERVERS_DIR, id))['level-type']; + const sentLevelType = String(req.body.levelType ?? '').trim(); + let levelTypeStr; + if (sentLevelType) { + levelTypeStr = worldTypeFor(sentLevelType, targetVersion) + || (sentLevelType === fileLevelType ? fileLevelType : null); + if (!levelTypeStr) { + return res.status(400).json({ error: `World type ${sentLevelType} isn't available for Minecraft ${targetVersion}.` }); + } + } else if (fileLevelType && targetVersion !== server.version) { + const translated = worldTypeFor(fileLevelType, targetVersion); + if (translated && translated !== fileLevelType) levelTypeStr = translated; + } + // Everything that mutates the server, deferred into one closure so the // backup path can run it only once a restore point exists. Throws // httpError; callers map that to a status code or a WebSocket failure. @@ -2343,6 +2386,7 @@ router.post('/servers/:id/edit', async (req, res) => { server.javaArgs = safeJavaArgs; server.gamemode = gamemodeStr; server.difficulty = difficultyStr; + if (levelTypeStr !== undefined) server.levelType = levelTypeStr; server.seed = seedStr; server.group = groupResult.value; await serversDb.set(`server_${id}`, server); @@ -2363,6 +2407,7 @@ router.post('/servers/:id/edit', async (req, res) => { 'server-port': String(portNum), 'gamemode': gamemodeStr, 'difficulty': difficultyStr, + ...(levelTypeStr !== undefined ? { 'level-type': levelTypeStr } : {}), 'level-seed': seedStr }); } diff --git a/src/routes/api-v1/templates.js b/src/routes/api-v1/templates.js index 2e0771f..f8b311f 100644 --- a/src/routes/api-v1/templates.js +++ b/src/routes/api-v1/templates.js @@ -83,6 +83,7 @@ router.post('/templates', async (req, res) => { javaArgs: server.javaArgs || '', gamemode: server.gamemode || 'survival', difficulty: server.difficulty || 'easy', + levelType: server.levelType || null, port: server.port || 25565, autoRestart: !!server.autoRestart, autoStart: !!server.autoStart, diff --git a/src/routes/servers.js b/src/routes/servers.js index 88d5037..1b62a26 100644 --- a/src/routes/servers.js +++ b/src/routes/servers.js @@ -10,6 +10,7 @@ const { formatSize, getDirectorySize } = require('../utils/resourceStats'); const { serversDb, SERVERS_DIR } = require('../db'); const { parseServerProperties } = require('../mc/serverProperties'); const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta'); +const { WORLD_PRESETS, LEGACY_WORLD_TYPES, worldTypesFor, worldTypeFor } = require('../mc/worldTypes'); const { log } = require('../utils/log'); const { hasIcon } = require('../utils/serverIcon'); const { isPathInside } = require('../utils/pathSafety'); @@ -23,6 +24,8 @@ router.get('/servers/create', ensureAuth, async (req, res) => { navbar: true, user: req.user, groupNames: await getDistinctGroups().catch(() => []), + // The version is picked on the page, so both World Type lists go along + worldTypeLists: { presets: WORLD_PRESETS, legacy: LEGACY_WORLD_TYPES }, messages: req.session.flash || {}, csrfToken: res.locals.csrfToken }); @@ -102,6 +105,13 @@ router.get('/servers/:id/edit', ensureAuth, blockWhileProvisioning, async (req, description: `Configure basic server and runtime settings for ${server.name}.`, server, currentMotd, + worldTypes: worldTypesFor(server.version), + // Read from the file, in the form this version reads (an upgraded + // server can still hold a pre-1.19 name). A server that has never + // started, or one from before 1.2.2, may have no level-type at all. + currentLevelType: props['level-type'] + ? (worldTypeFor(props['level-type'], server.version) || props['level-type']) + : worldTypesFor(server.version)[0].value, hasIcon: hasIcon(server.id), user: req.user, groupNames: await getDistinctGroups().catch(() => []), @@ -131,7 +141,11 @@ router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async description: `Edit server properties for ${server.name}.`, server, properties, - propertyMeta: PROPERTY_META, + // World Type lists the values the server's own version reads + propertyMeta: { + ...PROPERTY_META, + 'level-type': { ...PROPERTY_META['level-type'], options: worldTypesFor(server.version) } + }, groups: GROUPS, user: req.user, messages: req.session.flash || {}, diff --git a/src/utils/mcVersion.js b/src/utils/mcVersion.js index 5bf0280..02efb4f 100644 --- a/src/utils/mcVersion.js +++ b/src/utils/mcVersion.js @@ -16,4 +16,35 @@ function isReleaseVersion(v) { return RELEASE_RE.test(String(v || '')); } -module.exports = { MC_VERSION_RE, isReleaseVersion }; +const SNAPSHOT_RE = /^(\d{2})w(\d{2})/; + +/** + * Whether `version` is `release` or newer. Snapshot ids compare against + * `firstSnapshot`, the snapshot that opened `release`'s cycle; pre-release and + * rc ids count as their release. A blank version (a custom jar) or one in no + * known form is taken as current, since Craftbox tracks newer servers. + * + * @param {string} version - e.g. "1.18.2", "26.1", "22w45a", "1.19.3-pre1" + * @param {string} release - e.g. "1.19.3" + * @param {string} firstSnapshot - e.g. "22w42a" + */ +function isAtLeast(version, release, firstSnapshot) { + const v = String(version || '').trim(); + const snapshot = SNAPSHOT_RE.exec(v); + if (snapshot) { + const [, year, week] = snapshot; + const [, firstYear, firstWeek] = SNAPSHOT_RE.exec(firstSnapshot); + return Number(year) * 100 + Number(week) >= Number(firstYear) * 100 + Number(firstWeek); + } + const cleaned = v.replace(/[ _-]?(?:pre|rc).*$/i, ''); + if (!isReleaseVersion(cleaned)) return true; + const a = cleaned.split('.').map(Number); + const b = release.split('.').map(Number); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] || 0) - (b[i] || 0); + if (diff !== 0) return diff > 0; + } + return true; +} + +module.exports = { MC_VERSION_RE, isReleaseVersion, isAtLeast }; diff --git a/views/servers/create.ejs b/views/servers/create.ejs index d8dd636..4fe353d 100644 --- a/views/servers/create.ejs +++ b/views/servers/create.ejs @@ -151,8 +151,13 @@ +
+ + <%# World options. World Type's list depends on the chosen version + (pre-1.19 servers read different names), so create.js fills it + from these lists; the presets are rendered for a first paint. %>
-
+
-
+
-
+
+
+
+ + +
+
diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs index 55167f2..4588a98 100644 --- a/views/servers/edit.ejs +++ b/views/servers/edit.ejs @@ -92,8 +92,11 @@ const _gate = 'stopped crashed'; %>
+
+ + <%# World options, laid out as on the create page %>
-
+
-
+
-
+
+
+
+ + +
+
diff --git a/views/servers/properties.ejs b/views/servers/properties.ejs index 693b9f4..daea7d4 100644 --- a/views/servers/properties.ejs +++ b/views/servers/properties.ejs @@ -45,12 +45,13 @@ <% } else if (meta.type === 'enum') { %> <% if (meta.description) { %> From c90db3af9971dc7d1ffacf7eaa6de0b5f0a36fb5 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 16:19:36 +0100 Subject: [PATCH 2/6] Default new servers to Normal difficulty Normal is now preselected on the create page, and is what the API uses when a create request leaves difficulty out, in place of Easy. It is also the default for a new server.properties and for templates saved from a server with none recorded. Existing servers keep whatever they have. --- docs/API.md | 2 +- src/mc/propertyMeta.js | 2 +- src/mc/serverProperties.js | 2 +- src/routes/api-v1/servers.js | 2 +- src/routes/api-v1/templates.js | 2 +- views/servers/create.ejs | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/API.md b/docs/API.md index 718ace4..dcfbd0a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -110,7 +110,7 @@ The server object returned by these endpoints contains the full configuration (n | Method | Path | Description | |---|---|---| -| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background | +| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. `gamemode` defaults to `survival` and `difficulty` to `normal`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background | | POST | `/servers/from-modpack` | Create from a Modrinth modpack — see [Modrinth](#modrinth) | | POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack` file — see [Modrinth](#modrinth) | | POST | `/servers/:id/duplicate` | Clone a server. Body: `{name, port, includeWorld?, stopFirst?, startAfter?}`. `409` if running and `stopFirst` is not set. Returns `201` | diff --git a/src/mc/propertyMeta.js b/src/mc/propertyMeta.js index e162532..54aceb6 100644 --- a/src/mc/propertyMeta.js +++ b/src/mc/propertyMeta.js @@ -21,7 +21,7 @@ const PROPERTY_META = { { value: 'easy', label: 'Easy' }, { value: 'normal', label: 'Normal' }, { value: 'hard', label: 'Hard' } - ], default: 'easy' + ], default: 'normal' }, 'hardcore': { type: 'boolean', label: 'Hardcore', group: 'gameplay', default: 'false', description: 'One life only: players cannot respawn. Only applies when generating a new world' }, 'pvp': { type: 'boolean', label: 'PvP', group: 'gameplay', default: 'true' }, diff --git a/src/mc/serverProperties.js b/src/mc/serverProperties.js index 25cb256..b00cbf4 100644 --- a/src/mc/serverProperties.js +++ b/src/mc/serverProperties.js @@ -5,7 +5,7 @@ const { firstStartProperties } = require('./worldTypes'); const DEFAULT_PROPERTIES = { 'server-port': 25565, 'gamemode': 'survival', - 'difficulty': 'easy', + 'difficulty': 'normal', 'max-players': 20, 'motd': 'A Minecraft Server', 'online-mode': true, diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js index e447953..7d993e4 100644 --- a/src/routes/api-v1/servers.js +++ b/src/routes/api-v1/servers.js @@ -1083,7 +1083,7 @@ function validateBaseServerFields(body) { memoryNum, safeJavaArgs: String(javaArgs || '').trim(), gamemodeStr: validGamemodes.includes(gamemode) ? gamemode : 'survival', - difficultyStr: validDifficulties.includes(difficulty) ? difficulty : 'easy', + difficultyStr: validDifficulties.includes(difficulty) ? difficulty : 'normal', levelTypeStr, seedStr: String(seed || '').trim(), group: groupResult.value diff --git a/src/routes/api-v1/templates.js b/src/routes/api-v1/templates.js index f8b311f..3e91abe 100644 --- a/src/routes/api-v1/templates.js +++ b/src/routes/api-v1/templates.js @@ -82,7 +82,7 @@ router.post('/templates', async (req, res) => { memory: server.memory || 2048, javaArgs: server.javaArgs || '', gamemode: server.gamemode || 'survival', - difficulty: server.difficulty || 'easy', + difficulty: server.difficulty || 'normal', levelType: server.levelType || null, port: server.port || 25565, autoRestart: !!server.autoRestart, diff --git a/views/servers/create.ejs b/views/servers/create.ejs index 4fe353d..0d4a472 100644 --- a/views/servers/create.ejs +++ b/views/servers/create.ejs @@ -170,8 +170,8 @@
From b553433b45dc21af33f02ccfb12d507ced9ef2ac Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 16:19:36 +0100 Subject: [PATCH 3/6] Keep modpacks away from the server's launch files Forge and NeoForge start as `java ... @libraries/.../unix_args.txt`, and that file is a JVM argument file. A modpack's files and overrides land after the loader installs, so a pack could overwrite it, or add a higher-numbered folder beside it, which Craftbox picked, and put its own flags on the command line: -javaagent, or -XX:OnOutOfMemoryError running a shell command. A pack's mods already run inside the server, but how the JVM starts should not be the pack's to decide. Any pack file, from the manifest or the overrides, that would land under libraries/, as a .jar or *_args.txt at the top of the server directory, or as user_jvm_args.txt, run.sh, run.bat or fabric-server-launcher.properties is now skipped, and the install's warnings and the log name what was left out. Paths are checked in canonical form, so `./server.jar` or `Libraries/` don't slip past. Forge and NeoForge also launch the args file of the build Craftbox installed ahead of any other folder, falling back to the newest only when that build has none. A pack's recommended memory was checked too: the .mrpack format carries no memory setting, so the create page's 4 GB suggestion for modpacks stays. --- docs/API.md | 2 +- src/mc/ServerProcess.js | 15 +++++++++++++-- src/mc/modpackInstaller.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index dcfbd0a..d7c793a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -399,7 +399,7 @@ Two quirks of Modrinth's search are worked around inside the proxy, so these end | POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above | | POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack`. Multipart, file field `mrpack`, max 2 GiB, plus the same base fields as text fields. The pack is parsed and the loader resolved **before** any server record is created, so malformed or Quilt packs fail with a clean `400`. Returns `201` + background install. Also accepts [chunked uploads](#chunked-uploads-dgup) at `/servers/from-mrpack/upload/*` | -The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed and `levelType`, when given — left blank, the pack's are kept), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import. +The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. No pack file, whether from the manifest or the overrides, may land among the server's launch files: anything under `libraries/` (where Forge and NeoForge keep the argument file handed to the JVM), a `.jar` or `*_args.txt` at the top of the server directory, `user_jvm_args.txt`, `run.sh`/`run.bat` or `fabric-server-launcher.properties`. Such files are skipped and named in the `complete` message's `warnings`, so a pack cannot change how the server is started. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed and `levelType`, when given — left blank, the pack's are kept), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import. ### Install a mod or plugin into an existing server diff --git a/src/mc/ServerProcess.js b/src/mc/ServerProcess.js index 11feac7..1038f71 100644 --- a/src/mc/ServerProcess.js +++ b/src/mc/ServerProcess.js @@ -286,6 +286,17 @@ class ServerProcess extends EventEmitter { } } + /** + * The installed build's own folder first. Its args file becomes the JVM's + * command line, so another folder under libraries/ (one a modpack or an + * upload left there) mustn't outrank it. With no recorded build, or that + * folder missing its args file, the newest folder is used as before. + */ + _ownBuildFirst(versions, ownDir) { + if (!this.config.build || !versions.includes(ownDir)) return versions; + return [ownDir, ...versions.filter(v => v !== ownDir)]; + } + /** * Find Forge args file for 1.17+ style installations. */ @@ -303,7 +314,7 @@ class ServerProcess extends EventEmitter { } return 0; }); - for (const ver of versions) { + for (const ver of this._ownBuildFirst(versions, `${this.config.version}-${this.config.build}`)) { const argsName = process.platform === 'win32' ? 'win_args.txt' : 'unix_args.txt'; const argsPath = path.join(libDir, ver, argsName); if (fs.existsSync(argsPath)) { @@ -332,7 +343,7 @@ class ServerProcess extends EventEmitter { } return 0; }); - for (const ver of versions) { + for (const ver of this._ownBuildFirst(versions, String(this.config.build))) { const argsName = process.platform === 'win32' ? 'win_args.txt' : 'unix_args.txt'; const argsPath = path.join(libDir, ver, argsName); if (fs.existsSync(argsPath)) { diff --git a/src/mc/modpackInstaller.js b/src/mc/modpackInstaller.js index c09b0d8..fc2a16a 100644 --- a/src/mc/modpackInstaller.js +++ b/src/mc/modpackInstaller.js @@ -28,6 +28,22 @@ const DISK_HEADROOM_BYTES = 512 * 1024 * 1024; // by the filename listModFiles() reports for that folder). const MOD_JAR_RE = /^mods\/[^/]+\.jar$/i; +// Files that decide how the server starts rather than what it runs. The +// loader's own install tree (libraries/, where Forge and NeoForge keep the +// unix_args.txt handed to the JVM as its arguments), the jars at the top of the +// server directory, and the start scripts and argument files loaders ship. A +// pack's mods already run inside the server, but it gets no say in the JVM's +// command line (-javaagent, -XX:OnOutOfMemoryError=...), so a pack file that +// would land on one of these is skipped. +const LAUNCH_FILES = new Set(['user_jvm_args.txt', 'run.sh', 'run.bat', 'fabric-server-launcher.properties']); + +function isLaunchFile(serverDir, target) { + const rel = path.relative(path.resolve(serverDir), target).split(path.sep).join('/').toLowerCase(); + if (rel.startsWith('libraries/')) return true; + if (rel.includes('/')) return false; + return LAUNCH_FILES.has(rel) || rel.endsWith('.jar') || rel.endsWith('_args.txt'); +} + // server.properties settings a pack doesn't get to decide for the host, even // though the rest of its file is kept: switching off account checks, opening // RCON with a password everyone who has the pack knows, or binding to an @@ -206,6 +222,7 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl const files = []; const seenPaths = new Set(); const clientOnlyMods = []; + const skippedLaunchFiles = []; // Every jar that ends up in mods/, from the manifest or from an override. // Keyed by filename so an override replacing a manifest mod counts once. const modJars = new Set(); @@ -220,6 +237,10 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl } seenPaths.add(relKey); let dest = sanitizeEntryPath(serverDir, rel); + if (isLaunchFile(serverDir, dest)) { + skippedLaunchFiles.push(rel); + continue; + } if (MOD_JAR_RE.test(rel)) { modJars.add(path.basename(rel).toLowerCase()); // Client-only mods are still installed, but land pre-disabled and @@ -336,7 +357,9 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl const relative = entryName.slice(prefix.length); if (!relative) continue; const target = sanitizeEntryPath(serverDir, relative); - if (entry.isDirectory) { + if (isLaunchFile(serverDir, target)) { + if (!entry.isDirectory) skippedLaunchFiles.push(relative); + } else if (entry.isDirectory) { await fs.promises.mkdir(target, { recursive: true }); } else { await fs.promises.mkdir(path.dirname(target), { recursive: true }); @@ -347,6 +370,13 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl } } + if (skippedLaunchFiles.length > 0) { + const shown = skippedLaunchFiles.slice(0, 5).join(', ') + + (skippedLaunchFiles.length > 5 ? ` and ${skippedLaunchFiles.length - 5} more` : ''); + warnings.push(`Skipped ${skippedLaunchFiles.length} file(s) the modpack tried to place among the server's launch files (${shown}). A modpack can't change how the server is started.`); + log('warn', `Modpack install ${serverId}: skipped launch files: ${skippedLaunchFiles.join(', ')}`); + } + // ── Phase 6: finalize ── emit('finalize'); // After overrides on purpose: Craftbox-managed values (port, gamemode, From fe04094433a767e4f1e321825ede4706e39d8d68 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 16:19:36 +0100 Subject: [PATCH 4/6] ci: Check World Type, first-start settings, the pages and a .mrpack install The API smoke test now covers: - the Normal difficulty default and the first-start settings a new server gets, and the record's levelType; - World Type stored in the spelling the version reads, and unknown types refused on create and edit; - the Settings, Properties and create pages rendering the new fields, the first checks of any page's HTML, over the session the setup wizard opens; - a real .mrpack install (a Fabric pack built in the test) that plants launch files, which must all be skipped, while the pack's own server.properties values are kept and online-mode stays on. lib.mjs gains bootstrapPanel(), which also returns a logged-in page fetcher, and makeZip(), a stored-only zip writer so the test needs no zip dependency. --- test/ci/api-smoke.mjs | 109 ++++++++++++++++++++++++++++++++++++++---- test/ci/lib.mjs | 59 ++++++++++++++++++++++- 2 files changed, 158 insertions(+), 10 deletions(-) diff --git a/test/ci/api-smoke.mjs b/test/ci/api-smoke.mjs index 2da0fac..2219f78 100644 --- a/test/ci/api-smoke.mjs +++ b/test/ci/api-smoke.mjs @@ -1,19 +1,21 @@ // API smoke test: exercises the documented /api/v1 surface against a fresh // Craftbox instance, the way an integration would. Starting servers is left to -// server-start.mjs; this covers auth, validation, create, settings, files, -// backups and delete on one vanilla server. +// server-start.mjs; this covers auth, validation, create, settings, world +// options, the pages that show them, files, backups and delete on one vanilla +// server, plus a .mrpack install. // // CRAFTBOX_URL=http://localhost:6464 node test/ci/api-smoke.mjs import { - waitForPanel, bootstrapApiKey, apiClient, waitForState, - createRunner, assert, assertStatus, sleep + waitForPanel, bootstrapPanel, apiClient, waitForState, + createRunner, assert, assertStatus, sleep, makeZip } from './lib.mjs'; const EXPECTED_TYPES = ['vanilla', 'paper', 'purpur', 'folia', 'fabric', 'forge', 'neoforge', 'custom']; const PROVISION_TIMEOUT = 10 * 60_000; -// server.properties as the API returns it, parsed into an object +// server.properties as the API returns it, parsed into an object with the +// escapes Minecraft writes (minecraft\:flat) undone, as Craftbox reads it async function readProperties(api, id) { const res = await api('GET', `/servers/${id}/file?path=server.properties`); assertStatus(res, 200, 'read server.properties'); @@ -21,14 +23,14 @@ async function readProperties(api, id) { for (const line of res.body.file.content.split(/\r?\n/)) { if (!line || line.startsWith('#') || !line.includes('=')) continue; const eq = line.indexOf('='); - props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/\\:/g, ':').replace(/\\=/g, '='); } return props; } const run = createRunner('API smoke test'); await waitForPanel(); -const key = await bootstrapApiKey(); +const { key, page } = await bootstrapPanel(); const api = apiClient(key); const anon = apiClient(null); let latest = null; @@ -77,7 +79,7 @@ await run.step('rejects an unknown type in /versions', async () => { console.log('Create'); const validBody = () => ({ name: 'CI Smoke', serverType: 'vanilla', version: latest || 'latest', - port: 25565, memory: 2048, eula: true, gamemode: 'survival', difficulty: 'easy' + port: 25565, memory: 2048, eula: true // game mode, difficulty and world type left to their defaults }); await run.step('validates create input', async () => { const cases = [ @@ -87,6 +89,7 @@ await run.step('validates create input', async () => { ['bad name', { name: 'bad/name' }], ['unknown type', { serverType: 'bogus' }], ['bad version', { version: '../1.21' }], + ['unknown world type', { levelType: 'bogus' }], ['custom without a URL', { serverType: 'custom' }] ]; for (const [what, patch] of cases) { @@ -121,6 +124,20 @@ if (id) { assert(props[k] === 'true', `${k}=${props[k]}`); } assert(props['server-port'] === '25565', `server-port=${props['server-port']}`); + assert(props.gamemode === 'survival' && props.difficulty === 'normal', + `gamemode=${props.gamemode} difficulty=${props.difficulty} (expected survival/normal)`); + }); + await run.step('writes the settings Minecraft only reads on first start', async () => { + // The latest release uses 1.19+ world presets and 1.19.3+ datapack keys + const props = await readProperties(api, id); + const want = { + 'level-type': 'minecraft:normal', 'hardcore': 'false', 'generator-settings': '{}', + 'initial-enabled-packs': 'vanilla', 'initial-disabled-packs': '' + }; + const wrong = Object.keys(want).filter((k) => props[k] !== want[k]); + assert(wrong.length === 0, wrong.map((k) => `${k}=${props[k]}`).join(', ')); + const server = (await api('GET', `/servers/${id}`)).body.server; + assert(server.levelType === 'minecraft:normal', `record levelType ${server.levelType}`); }); await run.step('partial update leaves omitted toggles alone', async () => { assertStatus(await api('POST', `/servers/${id}/properties`, { 'max-players': 7 }), 200, 'partial update'); @@ -153,6 +170,7 @@ if (id) { assert(props['server-port'] === '25566' && props.gamemode === 'creative' && props.difficulty === 'hard', `properties: port=${props['server-port']} gamemode=${props.gamemode} difficulty=${props.difficulty}`); }); + await run.step('sets the MOTD', async () => { assertStatus(await api('POST', `/servers/${id}/motd`, { motd: 'CI smoke test' }), 200, 'motd'); assert((await readProperties(api, id)).motd === 'CI smoke test', 'motd not written'); @@ -175,6 +193,37 @@ if (id) { assert(groups.body.groups.some((g) => g.name === 'CI Group' && g.count === 1), 'group not listed'); }); + console.log('World options'); + const editBody = (extra) => ({ name: 'CI Smoke Edited', port: 25566, memory: 1536, ...extra }); + await run.step('stores World Type in the spelling the version reads', async () => { + const res = await api('POST', `/servers/${id}/edit`, editBody({ levelType: 'flat' })); + assertStatus(res, 200, 'edit levelType'); + assert(res.body.server.levelType === 'minecraft:flat', `record levelType ${res.body.server.levelType}`); + assert((await readProperties(api, id))['level-type'] === 'minecraft:flat', 'level-type not written'); + assertStatus(await api('POST', `/servers/${id}/edit`, editBody({ levelType: 'bogus' })), 400, 'unknown world type'); + }); + await run.step('shows the world options on the Settings page', async () => { + const res = await page(`/servers/${id}/edit`); + assert(res.status === 200, `Settings page returned ${res.status}`); + assert(/]*id="levelType"[^>]*name="levelType"/.test(res.html), 'no World Type select'); + assert(/