Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2cd8d0e
1.1.1 - Update `supportsTellraw` logic to support several possible ve…
WillTDA Jul 26, 2026
2d06db2
Bump better-sqlite3 from 13.0.1 to 13.0.2
dependabot[bot] Aug 1, 2026
b81b9d0
Bump express-rate-limit from 8.6.0 to 8.6.1
dependabot[bot] Aug 1, 2026
a2ba825
Bump sharp from 0.34.5 to 0.35.3
dependabot[bot] Aug 1, 2026
3bf07d7
Merge pull request #55 from diamonddigitaldev/dependabot/npm_and_yarn…
WillTDA Aug 3, 2026
573ab42
Merge pull request #54 from diamonddigitaldev/dependabot/npm_and_yarn…
WillTDA Aug 3, 2026
3457017
Merge pull request #53 from diamonddigitaldev/dependabot/npm_and_yarn…
WillTDA Aug 3, 2026
5b4e5fe
Document unlisted status page access as intentional (#58)
WillTDA Aug 3, 2026
6e78e53
Show absolute timestamp alongside relative age in the event log
WillTDA Aug 3, 2026
6f693e0
Lock upload modal inputs while a .mrpack or .cbx is uploading (#59)
WillTDA Aug 3, 2026
6d10bcf
Explain and recover from session expiry instead of an "unauthorized" …
WillTDA Aug 3, 2026
5fea21c
Block backups and management pages while a server is provisioning (#56)
WillTDA Aug 3, 2026
c25b30e
Fix Update Jar for imported and modpack-created servers (#60)
WillTDA Aug 3, 2026
005da39
Add API endpoints to read the mod/plugin list and environment map (#51)
WillTDA Aug 3, 2026
d2df3f9
Add file read/download endpoints and move export under /api/v1 (#57)
WillTDA Aug 3, 2026
dd1cd27
Release 1.1.1
WillTDA Aug 3, 2026
fba3921
Update `allowScripts`
WillTDA Aug 3, 2026
b42d709
Close the power-action race during a server restart
WillTDA Aug 3, 2026
58a2bcc
Track live server state in controls that require a stopped server
WillTDA Aug 3, 2026
588ecd6
Update the event log live over WebSocket
WillTDA Aug 3, 2026
f06faab
Add an API endpoint to read console output
WillTDA Aug 3, 2026
fc054e3
Promote release to 1.2.0-beta.1
WillTDA Aug 3, 2026
31b7942
Add file upload, rename and delete to the Files page
WillTDA Aug 4, 2026
68b7213
Stop a mod being listed twice when a disabled twin is left on disk
WillTDA Aug 4, 2026
3ba9ec8
Add a manual test checklist for the 1.2.0 betas
WillTDA Aug 4, 2026
ed786af
Promote release to 1.2.0-beta.2
WillTDA Aug 4, 2026
94e6858
Align action icons to right instead of center
WillTDA Aug 4, 2026
e38011e
Gate the new modals' confirm button on a valid name
WillTDA Aug 4, 2026
a2630ee
Fix the three issues found in the beta test pass
WillTDA Aug 4, 2026
a5f2daf
Promote release to 1.2.0-beta.3
WillTDA Aug 4, 2026
7d5c804
Stop a deferred backup still running after a Craftbox restart
WillTDA Aug 4, 2026
e81093b
Promote release to 1.2.0-beta.4
WillTDA Aug 4, 2026
5df7a7f
Add a New Text File button to the Files page
WillTDA Aug 6, 2026
3968cd5
Quote the destination folder in the create modals
WillTDA Aug 6, 2026
b8cb9b8
Send the server back button to its group page
WillTDA Aug 6, 2026
bac2b12
Centre the Assign Group field on the Settings page
WillTDA Aug 6, 2026
496e471
Rewrite the test checklist for 1.2.0-beta.5
WillTDA Aug 6, 2026
f628d3b
Promote release to 1.2.0-beta.5
WillTDA Aug 6, 2026
c7e2a58
Share the lone-row centring between the create and settings forms
WillTDA Aug 6, 2026
dcb6948
Add the create-page regression checks to the checklist
WillTDA Aug 6, 2026
3c82300
Run NPM audit fix
WillTDA Aug 16, 2026
09329d6
Decide text files by content instead of extension
WillTDA Aug 16, 2026
0484d36
Add the text file detection checks to the checklist
WillTDA Aug 16, 2026
27bc76c
Promote release to 1.2.0-beta.6
WillTDA Aug 16, 2026
e8fc292
Restore the cross-platform entries to the lockfile
WillTDA Aug 16, 2026
81f422d
Reject a typed name that is not a single path segment
WillTDA Aug 16, 2026
a337961
Record the beta.6 test pass in the checklist
WillTDA Aug 16, 2026
9ec22b7
Promote release to 1.2.0-beta.7
WillTDA Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div align="center">

![license](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)
![version](https://img.shields.io/badge/version-1.1.0-brightgreen?style=flat-square)
![version](https://img.shields.io/badge/version-1.2.0--beta.7-orange?style=flat-square)
![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square)

[![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord)
Expand Down
84 changes: 71 additions & 13 deletions docs/API.md

Large diffs are not rendered by default.

339 changes: 189 additions & 150 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftbox",
"version": "1.1.0",
"version": "1.2.0-beta.7",
"description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.",
"main": "src/server.js",
"funding": {
Expand Down Expand Up @@ -31,27 +31,27 @@
"dependencies": {
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^13.0.1",
"better-sqlite3": "^13.0.2",
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"content-disposition": "^1.0.1",
"ejs": "^6.0.1",
"express": "^5.2.1",
"express-rate-limit": "^8.6.0",
"express-rate-limit": "^8.6.1",
"express-session": "^1.19.0",
"material-icons": "^1.13.14",
"multer": "^2.1.1",
"node-stream-zip": "^1.16.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"quick.db": "^9.1.7",
"sharp": "^0.34.5",
"sharp": "^0.35.3",
"uuid": "^14.0.1",
"ws": "^8.21.1"
},
"allowScripts": {
"bcrypt@6.0.0": true,
"better-sqlite3@13.0.1": true,
"sharp@0.34.5": true
"better-sqlite3@13.0.2": true,
"sharp@0.35.3": true
}
}
19 changes: 6 additions & 13 deletions public/js/account.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
document.addEventListener('DOMContentLoaded', function () {
var csrfToken = document.querySelector('input[name="_csrf"]').value;

// ═══════════════════════════════════════════
// Change Username / Password
// ═══════════════════════════════════════════
Expand Down Expand Up @@ -105,15 +103,11 @@ document.addEventListener('DOMContentLoaded', function () {
showOverlay('Generating key...', 'Please wait while the key is created.');

try {
var res = await fetch('/api/v1/account/apikeys', {
var res = await apiFetch('/api/v1/account/apikeys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ name: name })
body: { name: name }
});
var data = await res.json().catch(function () { return {}; });
var data = res.data || {};
if (!res.ok) {
hideOverlay();
confirmCreateBtn.disabled = false;
Expand Down Expand Up @@ -186,13 +180,12 @@ document.addEventListener('DOMContentLoaded', function () {
showOverlay('Deleting key...', 'Please wait while the key is removed.');

try {
var res = await fetch('/api/v1/account/apikeys/' + encodeURIComponent(pendingDeleteId), {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
var res = await apiFetch('/api/v1/account/apikeys/' + encodeURIComponent(pendingDeleteId), {
method: 'DELETE'
});

if (!res.ok && res.status !== 204) {
var data = await res.json().catch(function () { return {}; });
var data = res.data || {};
hideOverlay();
confirmDeleteBtn.disabled = false;
showToast(data.message || data.error || 'Failed to delete key.', 'danger');
Expand Down
152 changes: 152 additions & 0 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,42 @@ async function apiFetch(path, options) {
if (res.status !== 204) {
try { data = await res.json(); } catch (_) { data = null; }
}
if (res.status === 401) _handleSessionExpired();
return { ok: res.ok, status: res.status, data: data };
}

// ── Session expiry ──
// Sessions are a 1-hour rolling idle timeout, so a tab left open overnight is
// signed out without anything on screen saying so. Every frontend call goes to
// /api/v1, which is guarded by ensureApiAuth ahead of CSRF validation, so an
// expired session is always a clean 401 — whose bare {error:'unauthorized'}
// body would otherwise reach the user as an unexplained "unauthorized" toast.
// Explain it instead and send them to sign in; ensureAuth's returnTo brings
// them back to the page they were on.
// The latch matters: pages fire several calls at once, and without it each one
// queues its own toast and races its own redirect.
var _sessionExpiredHandled = false;
function _handleSessionExpired() {
if (_sessionExpiredHandled) return;
if (window.location.pathname === '/login') return;
_sessionExpiredHandled = true;
flashToast('Your session has expired. Please sign in again.', 'warning');
window.location.href = '/login';
}

// The server rejects a WebSocket upgrade from an expired session with a 401,
// but browsers hide the handshake status from JS — all a client sees is a close
// with code 1006, identical to a network blip. So once a socket has failed to
// reconnect a few times, spend one cheap authenticated request to find out
// which it is: a 401 routes into the handling above, anything else means the
// panel is simply unreachable and the existing backoff should carry on.
// Called from every reconnect loop; probes at the 3rd failure and every 3rd
// after, which the 30s backoff cap keeps to at most one probe per 90s.
function probeSessionAfterFailures(attempts) {
if (attempts < 3 || attempts % 3 !== 0) return;
apiFetch('/api/v1/servers');
}

// ── Client-side date formatting ──
// Formats an ISO string to the user's local date/time.
// style: 'datetime' (default) = full date+time, 'date' = date only
Expand All @@ -56,6 +89,18 @@ function formatDate(isoString, style) {
});
}

// Formats a Date as a short relative age: "just now", "5m ago", "2h ago", "3d ago".
function timeAgo(date) {
var seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
var minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
var hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
var days = Math.floor(hours / 24);
return days + 'd ago';
}

// Auto-format all .format-date elements on page load
document.querySelectorAll('.format-date[data-iso]').forEach(function (el) {
el.textContent = formatDate(el.dataset.iso, el.dataset.style);
Expand Down Expand Up @@ -209,6 +254,110 @@ function guardFileInput(input, extensions, message) {
});
}

// ── Live state gating ──
// Controls that require a stopped server used to be gated once, server-side, at
// render time. The page then receives live state over the WebSocket, so the gate
// froze at whatever the state was when the page loaded: stop a server and the
// upload button stayed dead until a manual reload.
//
// Mark a control `data-enable-when="stopped crashed"` and it tracks the live
// state. `data-show-when` / `data-hide-when` toggle `.d-none` on the same basis
// — use them for the explanatory alerts that accompany a gate.
// Optional `data-disabled-title` / `data-enabled-title` swap the tooltip.
//
// The live state is read from #server-nav-header's data-state, which both
// WebSocket owners (serverState.js and console.js) write on every update.
function currentServerState() {
var el = document.getElementById('server-nav-header');
return (el && el.dataset.state) || '';
}

function isServerStopped(state) {
return ['stopped', 'crashed'].indexOf(state || currentServerState()) !== -1;
}

function applyStateGates(state) {
state = state || currentServerState();

document.querySelectorAll('[data-enable-when]').forEach(function (el) {
var ok = el.dataset.enableWhen.split(/\s+/).indexOf(state) !== -1;
if ('disabled' in el) {
el.disabled = !ok;
} else {
// Anchors have no disabled property. Bootstrap's .disabled kills
// pointer events on .btn; the attributes keep it out of the tab
// order and announce the state.
el.classList.toggle('disabled', !ok);
el.setAttribute('aria-disabled', String(!ok));
if (ok) el.removeAttribute('tabindex');
else el.setAttribute('tabindex', '-1');
}
var title = ok ? el.dataset.enabledTitle : el.dataset.disabledTitle;
if (title !== undefined) el.title = title;
});

document.querySelectorAll('[data-show-when]').forEach(function (el) {
el.classList.toggle('d-none', el.dataset.showWhen.split(/\s+/).indexOf(state) === -1);
});

document.querySelectorAll('[data-hide-when]').forEach(function (el) {
el.classList.toggle('d-none', el.dataset.hideWhen.split(/\s+/).indexOf(state) !== -1);
});

// Pages with bespoke gating (button labels, request payloads) listen for
// this rather than duplicating the attribute walk.
document.dispatchEvent(new CustomEvent('craftbox:stategates', { detail: { state: state } }));
}

document.addEventListener('craftbox:state', function (e) {
applyStateGates((e.detail && e.detail.state) || currentServerState());
});

// Server-rendered markup is already correct on load; this only matters for
// elements whose gate attributes were added without a matching server-side
// render, and it keeps the two paths from drifting.
applyStateGates();

// ── Lock every control inside a container during an async operation ──
// Buttons that dismiss a modal are deliberately left enabled: the upload flows
// wire `hide.bs.modal` to abort the transfer, so Cancel / X / Esc must stay
// reachable while everything else is frozen.
// Forms are marked [data-busy] so the required-field validator below cannot
// re-enable the submit button out from under the lock.
// Unlocking re-enables every control, so callers that derive a button's state
// from validation should re-run that check afterwards.
function setControlsLocked(root, locked) {
if (!root) return;
root.querySelectorAll('input, select, textarea, button:not([data-bs-dismiss="modal"])')
.forEach(function (el) { el.disabled = locked; });

var forms = Array.prototype.slice.call(root.querySelectorAll('form'));
if (root.tagName === 'FORM') forms.push(root);
forms.forEach(function (form) {
if (locked) form.setAttribute('data-busy', '');
else form.removeAttribute('data-busy');
});
}

// ── Centre form fields left alone on their row ──
// A .row down to one visible column renders as a lopsided half-width field
// pinned to the left edge: the create form's port field once modpack mode
// hides the version picker, or Assign Group, which sits alone by design.
// Centre those, and un-centre again if a sibling column comes back — callers
// with columns that appear and disappear re-run this as the layout changes.
// `root` scopes it to one form; every other row on the page is left alone.
function centerLoneRowItems(root) {
if (!root) return;
root.querySelectorAll('.row').forEach(function (row) {
var cols = row.querySelectorAll(':scope > [class*="col-"]');
if (cols.length === 0) return;
var visible = Array.prototype.filter.call(cols, function (c) {
return !c.classList.contains('d-none');
});
row.classList.toggle('justify-content-center', visible.length === 1);
});
}

// ── Required field validation — disable submit until all required fields are filled ──
// Applies to any <form> with a [data-validate-required] submit button inside it.
// The button stays disabled/muted until every [required] input in the form has a value.
Expand All @@ -219,6 +368,9 @@ function guardFileInput(input, extensions, message) {
if (!form) return;

function check() {
// A busy form is locked by setControlsLocked — leave its submit
// button alone or an incidental input/change event unlocks it.
if (form.hasAttribute('data-busy')) return;
var fields = form.querySelectorAll('[required]');
var allFilled = true;
fields.forEach(function (f) {
Expand Down
42 changes: 26 additions & 16 deletions public/js/backups.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,8 @@
document.addEventListener('craftbox:operation', handleOperation);

function resetBackupButton() {
var btn = document.getElementById('confirm-backup-btn');
if (btn) {
btn.disabled = false;
btn.textContent = needsStop ? 'Stop & Backup' : 'Create Backup';
}
if (confirmBackupBtn) confirmBackupBtn.disabled = false;
refreshBackupButton();
}
function resetRestoreButton() {
var btn = document.getElementById('confirm-restore-btn');
Expand All @@ -77,10 +74,25 @@
var createBackupBtn = document.getElementById('create-backup-btn');
var backupForm = document.getElementById('backup-form');
var backupNameInput = document.getElementById('backupName');
var backupStartAfterInput = document.getElementById('backupStartAfter');
var startAfterBackupCheckbox = document.getElementById('startAfterBackup');
var stopFirstInput = document.getElementById('backupStopFirst');
var needsStop = stopFirstInput && stopFirstInput.value === 'true';
var confirmBackupBtn = document.getElementById('confirm-backup-btn');

// Whether a backup has to stop the server first depends on the state at the
// moment you press the button, not the state the page was rendered with.
function needsStopNow() {
return !isServerStopped();
}

// Keep the confirm button honest as the state changes underneath the page.
function refreshBackupButton() {
if (!confirmBackupBtn) return;
var stop = needsStopNow();
confirmBackupBtn.classList.toggle('btn-warning', stop);
confirmBackupBtn.classList.toggle('btn-success', !stop);
confirmBackupBtn.textContent = stop ? 'Stop & Backup' : 'Create Backup';
}
document.addEventListener('craftbox:stategates', refreshBackupButton);
refreshBackupButton();

if (createBackupBtn) {
createBackupBtn.addEventListener('click', function () {
Expand All @@ -97,11 +109,6 @@
});
}

if (startAfterBackupCheckbox && backupStartAfterInput) {
startAfterBackupCheckbox.addEventListener('change', function () {
backupStartAfterInput.value = startAfterBackupCheckbox.checked ? 'true' : 'false';
});
}

if (backupForm) {
backupForm.addEventListener('submit', async function (e) {
Expand All @@ -114,16 +121,19 @@
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Creating...';
}
createBackupModal.hide();
var overlayTitle = needsStop ? 'Stopping server & creating backup...' : 'Creating backup...';
var stopFirst = needsStopNow();
var overlayTitle = stopFirst ? 'Stopping server & creating backup...' : 'Creating backup...';
showOverlay(overlayTitle, 'Compressing server files. This may take a moment.');

var name = backupNameInput ? backupNameInput.value.trim() : 'Manual Backup';
var res = await apiFetch('/api/v1/servers/' + serverId + '/backups', {
method: 'POST',
body: {
name: name || 'Manual Backup',
stopFirst: stopFirstInput ? stopFirstInput.value : 'false',
startAfter: backupStartAfterInput ? backupStartAfterInput.value : 'false'
stopFirst: stopFirst ? 'true' : 'false',
// Only meaningful when we're stopping it ourselves.
startAfter: (stopFirst && startAfterBackupCheckbox && startAfterBackupCheckbox.checked)
? 'true' : 'false'
}
});
if (!res.ok) {
Expand Down
Loading
Loading