diff --git a/README.md b/README.md index 08f216f..4da5687 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A Turkish project summary is available near the end of this README: [Türkçe Ö ## 📌 Project Status -**Current package version:** `0.2.0` +**Current package version:** `0.2.3` MSMS is under active development, and the whole of it now runs: the desktop manager, the **admin web panel**, the **public website with its store and @@ -622,7 +622,10 @@ per-server permissions. JavaScript and Python samples built against your own install's address. A key can be **disabled** — reversibly, unlike revoking — or deleted from the list. - **A documented REST surface** at `/api/v1`, with an OpenAPI document and a - human-readable reference the app serves itself. + human-readable reference **the app serves itself**, so it always matches the + version you are running rather than whatever the docs said last release. Both + the desktop app and the panel link to it from the key list, next to the code + samples. - **Audit log** of every action, with the actor, the source and the IP. ### Secure defaults @@ -643,6 +646,15 @@ Treat panel tokens and API keys as secrets. --- +### Closing MSMS with a server running + +It refuses, and says which servers are up. Confirm and it stops them the way you +would — the configured countdown is broadcast to players, the world is saved, +and anything still up after 45 seconds is killed rather than left orphaned +holding the world files. + +--- + ## 🛒 Store & Economy A server currency and shop that players use from the public website. @@ -690,7 +702,9 @@ a firewall rule rather than with trust. note; hovering or clicking it shows them. Created by selecting chunks on the map, by typing coordinates, or over the API. - **The same map engine** as the panel and the website, so all four surfaces - draw the same world. + draw the same world — and **your website's colours**, so it looks like part of + the same server rather than a different product. +- **Touch:** one finger pans, two pinch, and a tap opens an area's note. Terrain is read from the server's own region files. MSMS never *generates* world — a map that could grow a world by being panned would be a map that can @@ -751,6 +765,9 @@ fill a disk. | Fullscreen map page (own listener) | ✅ | | Named chunk areas | ✅ | | Item icons + block colours from the client jar | ✅ | +| Named roles, assigned per server | ✅ | +| Touch / mobile map (pan, pinch, tap) | ✅ | +| Guarded shutdown while a server runs | ✅ | | MSMS Bridge plugin | ✅ builds, lightly field-tested | | Full visual website/CMS builder | 🗺️ Planned / evolving | @@ -1490,6 +1507,9 @@ No. - Kendi portunda **tam ekran harita sayfası** - **Adlandırılmış chunk alanları** — renk, ad ve not; arayüzden veya API ile - Item görselleri ve harita renkleri **Mojang'ın kendi client jar'ından** +- Sunucu başlıkken kapatmaya karşı **koruma** — onaylarsan geri sayımla düzgün durdurur +- Haritada **dokunmatik** — tek parmak kaydırma, iki parmak yakınlaştırma +- Sunucu bazlı **adlandırılmış roller** ### Taşınabilir çalışma mantığı diff --git a/package.json b/package.json index 7b4c3fc..8f574e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msms", - "version": "0.2.2", + "version": "0.2.3", "description": "Minecraft Server Management System — portable, bilingual (EN/TR) desktop control panel for Minecraft servers.", "author": "CaYatur", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index d90ea90..f1f4c08 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,10 @@ -import { app, BrowserWindow, shell, Menu, session } from 'electron' +import { app, BrowserWindow, shell, Menu, session, dialog } from 'electron' import { join } from 'node:path' import { existsSync } from 'node:fs' import { loadConfig, getConfig } from './config' import { registerIpc } from './ipc/register' import { processManager } from './core/processManager' +import { listServers } from './core/serverRegistry' import { initScheduler, stopAllJobs } from './core/scheduler' import { initWebServer, stopWebServer } from './web/server' import { initEconomy } from './store/economy' @@ -41,6 +42,89 @@ let splash: BrowserWindow | null = null let splashShownAt = 0 let cleanupDone = false + +/** + * Refuse to close while a server is running, and say why. + * + * Quitting used to stop every server with `immediate: true` — no countdown, no + * warning, no chance to say no. From the operator's side that is the app + * closing and their players being dropped without a word; from a player's side + * it is the server vanishing mid-sentence. + * + * So: ask first, and then stop properly. `immediate` is gone — the configured + * countdown runs, which broadcasts to players and gives the world time to save. + * A server that will not go down in time is killed rather than left orphaned, + * because the alternative is a Java process holding the world files after MSMS + * has exited. + */ +let quitConfirmed = false + +function runningServerNames(): string[] { + return listServers() + .filter((s) => processManager.isRunning(s.id)) + .map((s) => s.name) +} + +async function confirmQuit(parent?: BrowserWindow): Promise { + if (quitConfirmed) return true + const names = runningServerNames() + if (!names.length) return true + const tr = getConfig().language === 'tr' + const list = names.join(', ') + const opts = { + type: 'warning' as const, + buttons: tr ? ['Sunucuları durdur ve kapat', 'Vazgeç'] : ['Stop servers and quit', 'Cancel'], + defaultId: 1, + cancelId: 1, + title: tr ? 'Sunucu hâlâ çalışıyor' : 'A server is still running', + message: tr + ? names.length === 1 + ? `"${list}" hâlâ çalışıyor.` + : `${names.length} sunucu hâlâ çalışıyor: ${list}` + : names.length === 1 + ? `"${list}" is still running.` + : `${names.length} servers are still running: ${list}`, + detail: tr + ? 'Kapatmadan önce durdurulacaklar. Oyunculara geri sayım duyurulur ve dünya kaydedilir; bu birkaç saniye sürebilir.' + : 'They will be stopped before MSMS exits. Players get the countdown and the world is saved, which can take a few seconds.' + } + const r = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + if (r.response !== 0) return false + quitConfirmed = true + return true +} + +/** Stop everything the way an operator would, then give up on stragglers. */ +async function shutdownServers(): Promise { + const running = listServers().filter((s) => processManager.isRunning(s.id)) + if (!running.length) return + log.info(`Shutting down — stopping ${running.length} running server(s) with the usual countdown…`) + await Promise.all( + running.map(async (s) => { + try { + // A ceiling, so one wedged server cannot keep the app alive forever. + // Whatever is still up after it is killed rather than orphaned. + await Promise.race([ + processManager.stop(s.id), + new Promise((r) => setTimeout(r, 45_000)) + ]) + } catch (e) { + log.warn(`Stopping ${s.name} failed:`, e) + } + if (processManager.isRunning(s.id)) { + log.warn(`${s.name} did not stop in time; killing it`) + try { + await processManager.kill(s.id) + } catch { + /* nothing left to try */ + } + } + }) + ) +} + function createSplash(): void { splash = new BrowserWindow({ width: 440, @@ -103,6 +187,18 @@ function createWindow(): void { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + mainWindow.on('close', (e) => { + // Asked HERE as well as on before-quit: closing the window is the usual way + // out, and a dialog that appears after the window has already gone reads as + // the app having crashed and then argued about it. + if (quitConfirmed || cleanupDone) return + if (!runningServerNames().length) return + e.preventDefault() + void confirmQuit(mainWindow ?? undefined).then((ok) => { + if (ok) mainWindow?.close() + }) + }) + mainWindow.on('closed', () => { mainWindow = null }) @@ -306,11 +402,12 @@ if (!gotLock) { app.on('before-quit', async (e) => { if (cleanupDone) return e.preventDefault() - log.info('Shutting down — stopping running servers…') + // Every other way out — the menu, a signal, the taskbar — lands here. + if (!(await confirmQuit(mainWindow ?? undefined))) return try { stopAllJobs() stopWebServer() - await processManager.stopAll() + await shutdownServers() flushMetrics() } catch (err) { log.error('Error during shutdown:', err) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index ea2c416..ac544de 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -447,6 +447,19 @@ export function registerIpc(): void { ) H(IPC.webUserDelete, (_e, id: string) => auth.deleteUser(id)) H(IPC.webUserPerms, (_e, id: string, perms: Record) => auth.setUserPerms(id, perms)) + // Assigning named roles, which the panel has always offered and which has + // never reached the main process — same omission as the rbac channels. + H(IPC.webUserRoles, (_e, id: string, roleMap: Record) => { + const username = auth.setUserRoles(id, roleMap) + audit.record({ + source: 'panel', + action: 'user.roles', + actor: 'operator', + target: username, + detail: Object.keys(roleMap ?? {}).length + ' servers' + }) + return username + }) H(IPC.webUserAudit, (_e, id: string, canAudit: boolean) => { // Granting/revoking sight of the personal-data audit log is itself audited. const username = auth.setUserAudit(id, !!canAudit) @@ -461,6 +474,25 @@ export function registerIpc(): void { H(IPC.webUserPassword, (_e, id: string, password: string) => auth.setUserPassword(id, password)) H(IPC.webUserMc, (_e, id: string, mcName: string) => auth.setUserMc(id, mcName)) + // --- named roles (#28) --- + // + // Declared in `ipc.ts`, exposed in the preload, called by the panel — and + // never registered here, so every one of them answered "No handler + // registered for 'rbac:list-roles'" from the day they were written. It went + // unnoticed because the view loaded them inside a chain that swallowed the + // rejection, which is also why the API keys after them never appeared (#153). + H(IPC.rbacList, () => roles.listRoles()) + H(IPC.rbacUpsert, (_e, role: Partial & { id?: string }) => { + const r = roles.upsertRole(role) + audit.record({ source: 'panel', action: 'role.upsert', actor: 'operator', target: r.name }) + return r + }) + H(IPC.rbacDelete, (_e, roleId: string) => { + const gone = roles.listRoles().find((r) => r.id === roleId) + roles.deleteRole(roleId) + audit.record({ source: 'panel', action: 'role.delete', actor: 'operator', target: gone?.name ?? roleId }) + }) + // --- API keys (#48). Desktop is the owner console, so no scope gate here; // the HTTP surface has its own owner check. Issue/revoke are audited on both // paths, because "who minted this credential" is the whole question later. diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 9a918ef..0467ecb 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -2241,6 +2241,50 @@ export async function runWorldsSmoke(): Promise { rmSync(emptyZip, { force: true }) console.log('WORLDS-SMOKE: import refuses zip-slip and worldless archives, cleans up after itself') + // --- 11z. every IPC channel the preload exposes must have a handler ----- + // + // `rbac:list-roles`, `rbac:upsert-role` and `rbac:delete-role` were + // declared in ipc.ts, exposed by the preload and called by the panel — and + // never registered. Every one answered "No handler registered for + // 'rbac:list-roles'" from the day they were written, and nothing noticed + // because the caller swallowed the rejection (#153). + // + // Read out of the SOURCE rather than from ipcMain, because the smoke + // registers handlers itself and asking the live process would only prove + // that this run wired them up. + { + const ipcSrc = readFileSync(join(process.cwd(), 'src', 'shared', 'ipc.ts'), 'utf-8') + const regSrc = readFileSync(join(process.cwd(), 'src', 'main', 'ipc', 'register.ts'), 'utf-8') + const preSrc = readFileSync(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf-8') + + // `name: 'channel:string',` inside the channel table. + const declared = [...ipcSrc.matchAll(/^\s{2}(\w+):\s*'([a-z0-9:-]+)',?$/gim)].map((m) => m[1]) + if (declared.length < 40) return fail('only found ' + declared.length + ' IPC channels; the scan is wrong') + + // COMMENTS STRIPPED FIRST. Without this the check is vacuous: commenting + // a handler out leaves `IPC.rbacList` sitting in the comment, the scan + // finds it, and the test stays green over a channel that answers "no + // handler registered". Proved by doing exactly that. + const code = regSrc + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + + const missing: string[] = [] + for (const key of declared) { + // Only channels the renderer can actually reach are worth this: a + // channel declared and used nowhere is dead code, not a broken call. + if (!preSrc.includes('IPC.' + key)) continue + // The registration itself, not a mention of the name — and tolerant of + // the multi-line form several handlers use, where the channel sits on + // its own line under the call. + if (!new RegExp('H\\(\\s*IPC\\.' + key + '\\b').test(code)) missing.push(key) + } + if (missing.length) { + return fail('the preload calls ' + missing.join(', ') + ' and nothing registers a handler') + } + console.log('SMOKE: every IPC channel the preload exposes has a handler') + } + // --- 12a. a region parse must not freeze the process (#151) ------------- { // A real region file: 1024 slots, each a deflated NBT compound. The diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index 7b3ba11..378d753 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -4,7 +4,7 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi' import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi' import { avatarUrl } from '@shared/profile' import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons' -import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage' +import { usageSamples, API_KEY_HEADER, USAGE_NOTES, REPO_URL } from '@shared/apiUsage' import { API_PREFIX } from '@shared/apiSurface' import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' import { AREA_COLOURS } from '@shared/chunkAreas' @@ -1103,6 +1103,7 @@ var avatarUrl=${avatarUrl.toString()}; var API_PREFIX=${JSON.stringify(API_PREFIX)}; var API_KEY_HEADER=${JSON.stringify(API_KEY_HEADER)}; var API_USAGE_NOTES=${JSON.stringify(USAGE_NOTES)}; +var API_REPO_URL=${JSON.stringify(REPO_URL)}; var apiUsageSamples=${usageSamples.toString()}; var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)}; var MAP_ICONS=STRUCTURE_ICONS; @@ -1389,7 +1390,13 @@ function showKeyUsage(id){ API_USAGE_NOTES.map(function(n){return esc(n)}).join('
')+''+ s.map(function(x){return '
'+esc(x.lang)+''+ '
'+esc(x.code)+'
'}).join('')} + 'padding:9px;border-radius:8px;margin:4px 0 0">'+esc(x.code)+''}).join('')+ + /* Where to go next: the reference this install serves itself, so it always + matches the running version, and the repository for the written docs. */ + ''} function createKey(){var label=document.getElementById('kLabel').value.trim();if(!label){alert('Give the key a label.');return} var scopes=KEY_SCOPES.filter(function(s){return kScopeSel[s]}); var all=document.getElementById('kAll').checked; diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index 2d6d082..ab46c48 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -975,6 +975,8 @@ export default { keyNoServers: 'no servers', noKeys: 'No API keys yet.', noServers: 'No servers yet.', + apiDocs: 'Route reference', + apiRepo: 'Documentation & source', apiOrigins: 'Browser origins allowed to call the API', apiOriginsHint: 'One origin per line, e.g. https://dash.example.com. Empty means no browser page may call the API from another site — which is the right setting unless you are building one. There is no wildcard.', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index ed138f9..07ec2a0 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -980,6 +980,8 @@ const tr: typeof en = { keyNoServers: 'sunucu yok', noKeys: 'Henüz API anahtarı yok.', noServers: 'Henüz sunucu yok.', + apiDocs: 'Rota referansı', + apiRepo: 'Dokümantasyon ve kaynak', apiOrigins: "API'yi çağırabilecek tarayıcı kaynakları", apiOriginsHint: 'Her satıra bir kaynak, örn. https://panel.ornek.com. Boş bırakmak, hiçbir tarayıcı sayfasının API\'yi başka bir siteden çağıramayacağı anlamına gelir — böyle bir şey geliştirmiyorsan doğru ayar budur. Joker karakter yoktur.', diff --git a/src/renderer/src/views/WebPanelView.tsx b/src/renderer/src/views/WebPanelView.tsx index 86721d8..4acb630 100644 --- a/src/renderer/src/views/WebPanelView.tsx +++ b/src/renderer/src/views/WebPanelView.tsx @@ -22,7 +22,7 @@ import { effectiveScopes } from '@shared/rbac' import { isKeyUsable } from '@shared/apikeys' import { MAP_PAGE_DEFAULTS } from '@shared/mapPage' import type { MapPageConfig, MapPageAccess } from '@shared/mapPage' -import { usageSamples, USAGE_NOTES } from '@shared/apiUsage' +import { usageSamples, USAGE_NOTES, REPO_URL } from '@shared/apiUsage' import type { RoleDef } from '@shared/rbac' import type { ApiKeyView, KeyServers } from '@shared/apikeys' import type { Scope, WebRole, WebStatus, WebUserView } from '@shared/web' @@ -779,6 +779,25 @@ export function WebPanelView(): JSX.Element { ))} + {/* Where to go next. The reference at /docs is served by this + install so it always matches the running version; the repo + is where the written documentation and the source are. */} +
+ + +
)} diff --git a/src/shared/apiUsage.ts b/src/shared/apiUsage.ts index 4ac6022..bf0b383 100644 --- a/src/shared/apiUsage.ts +++ b/src/shared/apiUsage.ts @@ -107,8 +107,12 @@ export function usageSamples(opts: { ] } +/** Where the written documentation lives, for the "learn more" link. */ +export const DOCS_URL = 'https://github.com/CaYatur/MinecraftServerManagementSystem#-web-panel' +export const REPO_URL = 'https://github.com/CaYatur/MinecraftServerManagementSystem' + /** - * The three sentences an operator needs before any of the above makes sense. + * The four sentences an operator needs before any of the above makes sense. * * A plain value, unlike `usageSamples` — it reaches the pages as JSON, so it may * refer to the shared constants directly. @@ -116,5 +120,6 @@ export function usageSamples(opts: { export const USAGE_NOTES = [ 'Send the key in the ' + API_KEY_HEADER + ' header. It is never accepted in a query string, where it would end up in server logs and browser history.', 'A key carries scopes, never a role: 401 means the key is wrong, disabled or expired; 403 means it is a good key without the scope that route needs.', - 'The full route list, with the scope each one requires, is at ' + API_PREFIX + '/docs.' + 'The full route list, with the scope each one requires, is at ' + API_PREFIX + '/docs — served by this install, so it always matches the version you are running.', + 'Machine-readable: ' + API_PREFIX + '/openapi.json. Written documentation and the source: ' + REPO_URL + '.' ]