Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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ığı

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
103 changes: 100 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<boolean> {
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<void> {
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,
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Scope[]>) => 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<string, string[]>) => {
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)
Expand All @@ -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<RoleDef> & { 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.
Expand Down
44 changes: 44 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,50 @@ export async function runWorldsSmoke(): Promise<void> {
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
Expand Down
11 changes: 9 additions & 2 deletions src/main/web/panelHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1389,7 +1390,13 @@ function showKeyUsage(id){
API_USAGE_NOTES.map(function(n){return esc(n)}).join('<br/>')+'</div>'+
s.map(function(x){return '<div style="margin-top:8px"><b style="font-size:12px">'+esc(x.lang)+'</b>'+
'<pre style="white-space:pre-wrap;word-break:break-word;font-size:11.5px;background:var(--elev);'+
'padding:9px;border-radius:8px;margin:4px 0 0">'+esc(x.code)+'</pre></div>'}).join('')}
'padding:9px;border-radius:8px;margin:4px 0 0">'+esc(x.code)+'</pre></div>'}).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. */
'<div class="row" style="gap:8px;margin-top:10px">'+
'<a class="btn sm" target="_blank" rel="noopener" href="'+API_PREFIX+'/docs">Route reference</a>'+
'<a class="btn sm" target="_blank" rel="noopener" href="'+mapEsc(API_REPO_URL)+'">Documentation &amp; source</a>'+
'</div>'}
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;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
21 changes: 20 additions & 1 deletion src/renderer/src/views/WebPanelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -779,6 +779,25 @@ export function WebPanelView(): JSX.Element {
</pre>
</div>
))}
{/* 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. */}
<div className="row wrap" style={{ gap: 8, marginTop: 12 }}>
<button
className="btn sm"
onClick={() =>
window.msms.openExternal(
(status?.panel.urls[0] ?? `http://127.0.0.1:${status?.panel.port ?? 8080}`) +
'/api/v1/docs'
)
}
>
<BookOpen size={13} /> {t('web.apiDocs')}
</button>
<button className="btn sm" onClick={() => window.msms.openExternal(REPO_URL)}>
<ExternalLink size={13} /> {t('web.apiRepo')}
</button>
</div>
</div>
)}
</div>
Expand Down
Loading
Loading