Skip to content

Commit 2696862

Browse files
authored
Install the Bridge plugin from GitHub Releases, on approval (#103) (#110)
Half of what MSMS can show — true TPS, MSPT, live player positions, the world map — needs the Bridge plugin, and installing it meant reading a README, running `node bridge/build.mjs`, and copying a jar by hand. Most people who install a server manager will never do that, so the features that depend on it looked broken rather than optional. `shared/bridgeRelease.ts` finds the newest published jar across this repository's releases and decides what to say about a server. Tag-agnostic, so the app's release and the plugin's do not have to be the same one. The asset name is matched against an anchored pattern — a release carries whatever its author uploaded, and "contains MSMS-Bridge" would let a `.txt.jar` through — and candidates are ordered by the jar's own version rather than by publication date, because the version is what the update check compares; sorting by one and comparing the other lets a re-published older jar read as an upgrade. Drafts and pre-releases are skipped: clicking install on a warning is not opting into a test build. The jar the app ships is the fallback. An offline LAN box is exactly where a server manager runs, and requiring the internet to install a 6 KB plugin would be a poor trade. It lives in `resources/` and stays out of the asar — it is a file an operator may reasonably want to find and copy by hand, which was the only way to install it before this change. `core/bridgeInstall.ts` does the work with the Modrinth installer's security shape, for the Modrinth installer's reasons: the caller names nothing. No URL, no path and no version string reaches the downloader. The URL is re-checked against GitHub's own hosts at the last line before the download, the published `sha256:` digest is verified when there is one, and older jars are removed only after the new one has landed — Bukkit refuses the second plugin with a name it has already loaded, so an update that only added a file would leave the server logging a duplicate-name error. The warning sits next to the feature it disables, in the desktop live map and the panel's map tab: this is where someone finds out positions are missing, so it is the only place the answer helps. Nothing is written without a click, and the result says to restart the server — Bukkit loads plugins at startup, so "installed" is not "working". New: `GET /servers/{id}/bridge` and `POST /servers/{id}/bridge/install`, both on `files`, which already permits writing a jar into the server folder. Asserted in `MSMS_SMOKE_MODUPDATE` (asset picking, the refusals, the version comparison, the need table, and that the bundled jar's version matches `plugin.yml` — a committed build output can drift from its sources and nothing else would notice) and in `MSMS_SMOKE_WEB` (scope gates, and that supplying a url/version/name in the install body changes nothing).
1 parent ad44649 commit 2696862

16 files changed

Lines changed: 983 additions & 16 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ Thumbs.db
3030

3131
# Minecraft server artifacts that may appear if a server is created in the repo root
3232
*.jar
33+
# ...except the Bridge plugin the app ships and installs on request (#103). It is
34+
# build output, but of ours, 6 KB, and the offline fallback depends on it being
35+
# present in a checkout rather than on whoever builds having a JDK.
36+
!resources/MSMS-Bridge-*.jar
3337
eula.txt
3438
world/
3539
world_nether/

docs/openapi.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2653,6 +2653,94 @@
26532653
}
26542654
}
26552655
},
2656+
"/api/v1/servers/{id}/bridge": {
2657+
"get": {
2658+
"operationId": "getServersIdBridge",
2659+
"summary": "Whether this server needs the MSMS Bridge plugin, and where one would come from.",
2660+
"description": "Scope `files` on the server.\n\nAnswers `state: unsupported` for a server type that cannot run it, and `offline: true` when the GitHub release check failed — the bundled jar still installs.",
2661+
"tags": [
2662+
"mods"
2663+
],
2664+
"parameters": [
2665+
{
2666+
"name": "id",
2667+
"in": "path",
2668+
"required": true,
2669+
"description": "Server id, as returned by GET /servers.",
2670+
"schema": {
2671+
"type": "string"
2672+
}
2673+
}
2674+
],
2675+
"responses": {
2676+
"200": {
2677+
"description": "Success."
2678+
},
2679+
"400": {
2680+
"description": "Malformed request, or a missing confirmation."
2681+
},
2682+
"401": {
2683+
"description": "No usable credential."
2684+
},
2685+
"403": {
2686+
"description": "Authenticated, but not permitted — the body names what was needed."
2687+
},
2688+
"404": {
2689+
"description": "No such server, or no such route."
2690+
},
2691+
"409": {
2692+
"description": "Conflicts with the current state (running server, name taken, …)."
2693+
},
2694+
"429": {
2695+
"description": "Rate limited. `Retry-After` says for how long."
2696+
}
2697+
}
2698+
}
2699+
},
2700+
"/api/v1/servers/{id}/bridge/install": {
2701+
"post": {
2702+
"operationId": "postServersIdBridgeInstall",
2703+
"summary": "Install the newest Bridge plugin jar, replacing any older one.",
2704+
"description": "Scope `files` on the server.\n\nTakes no body. The version and the download URL are resolved by the app from this project's own GitHub releases, falling back to the jar shipped with it; a caller naming either would turn a `files` request into an arbitrary write.",
2705+
"tags": [
2706+
"mods"
2707+
],
2708+
"parameters": [
2709+
{
2710+
"name": "id",
2711+
"in": "path",
2712+
"required": true,
2713+
"description": "Server id, as returned by GET /servers.",
2714+
"schema": {
2715+
"type": "string"
2716+
}
2717+
}
2718+
],
2719+
"responses": {
2720+
"200": {
2721+
"description": "Success."
2722+
},
2723+
"400": {
2724+
"description": "Malformed request, or a missing confirmation."
2725+
},
2726+
"401": {
2727+
"description": "No usable credential."
2728+
},
2729+
"403": {
2730+
"description": "Authenticated, but not permitted — the body names what was needed."
2731+
},
2732+
"404": {
2733+
"description": "No such server, or no such route."
2734+
},
2735+
"409": {
2736+
"description": "Conflicts with the current state (running server, name taken, …)."
2737+
},
2738+
"429": {
2739+
"description": "Rate limited. `Retry-After` says for how long."
2740+
}
2741+
}
2742+
}
2743+
},
26562744
"/api/v1/servers/{id}/store/admin": {
26572745
"get": {
26582746
"operationId": "getServersIdStoreAdmin",

electron-builder.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ files:
1111
- package.json
1212
- '!**/{.eslintrc,.editorconfig,tsconfig*.json,electron.vite.config.ts}'
1313

14+
# The Bridge plugin jar, kept OUT of the asar on purpose (#103). It is a file an
15+
# operator may reasonably want to find and drop into plugins/ by hand — that was
16+
# the only way to install it before the app could — and burying it inside an
17+
# archive would remove that escape hatch to save nothing.
18+
extraResources:
19+
- from: resources
20+
to: bridge
21+
filter:
22+
- '*.jar'
23+
1424
# We deliberately ship a single portable exe: it stores all data next to itself
1525
# (the launch directory), which is exactly the "portable" behaviour we want.
1626
win:

resources/MSMS-Bridge-1.0.0.jar

6.26 KB
Binary file not shown.

src/main/core/bridgeInstall.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/**
2+
* Install the Bridge plugin, from GitHub Releases or the copy shipped with the
3+
* app (#103).
4+
*
5+
* The security shape is the Modrinth installer's, for the Modrinth installer's
6+
* reasons: the caller names nothing. No URL, no path and no version string
7+
* reaches the downloader — a caller asks for "the bridge, on this server" and
8+
* this module resolves what that means. Anything less makes an authenticated
9+
* `files` request into "write a file of my choosing into your server folder".
10+
*/
11+
import { app } from 'electron'
12+
import { createHash } from 'node:crypto'
13+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
14+
import { join } from 'node:path'
15+
import { getServer } from './serverRegistry'
16+
import { httpJson, downloadFile } from './net'
17+
import * as audit from './audit'
18+
import { log } from '../logger'
19+
import type { AuditSource } from '@shared/audit'
20+
import {
21+
BRIDGE_JAR_RE,
22+
BRIDGE_REPO,
23+
bridgeNeed,
24+
bridgeSupported,
25+
bridgeVersionOf,
26+
compareBridgeVersions,
27+
isGithubAssetUrl,
28+
pickBridgeAsset,
29+
type BridgeAsset,
30+
type BridgeInstallResult,
31+
type BridgeStatus,
32+
type GhRelease
33+
} from '@shared/bridgeRelease'
34+
35+
// ---- what is installed ----
36+
37+
function pluginsDir(serverId: string): string {
38+
const s = getServer(serverId)
39+
if (!s) throw new Error('server-not-found')
40+
return join(s.path, 'plugins')
41+
}
42+
43+
/**
44+
* Every bridge jar in `plugins/`, newest first.
45+
*
46+
* A list rather than a single answer because finding two is the case that
47+
* matters: Bukkit loads both, the second one fails on a duplicate plugin name,
48+
* and the operator sees "MSMS-Bridge could not be enabled" with no hint that
49+
* the cause is the jar they installed sitting next to the one they already had.
50+
*/
51+
function installedJars(serverId: string): { name: string; version: string }[] {
52+
const dir = pluginsDir(serverId)
53+
if (!existsSync(dir)) return []
54+
const out: { name: string; version: string }[] = []
55+
for (const name of readdirSync(dir)) {
56+
const version = bridgeVersionOf(name)
57+
if (version) out.push({ name, version })
58+
}
59+
return out.sort((a, b) => compareBridgeVersions(b.version, a.version))
60+
}
61+
62+
export function installedBridgeVersion(serverId: string): string | null {
63+
return installedJars(serverId)[0]?.version ?? null
64+
}
65+
66+
// ---- the copy that ships with the app ----
67+
68+
/**
69+
* `resources/` in the repo during development; `resources/bridge/` next to the
70+
* executable once packaged (see `extraResources` in electron-builder.yml).
71+
*
72+
* Kept OUT of the asar deliberately. It is a file an operator may reasonably
73+
* want to find and copy by hand — that was the only way to install it before
74+
* this change — and burying it inside an archive to save nothing would remove
75+
* the escape hatch this whole feature is a convenience for.
76+
*/
77+
function bundledDir(): string {
78+
return app.isPackaged ? join(process.resourcesPath, 'bridge') : join(process.cwd(), 'resources')
79+
}
80+
81+
export function bundledBridge(): { version: string; path: string; name: string } | null {
82+
const dir = bundledDir()
83+
if (!existsSync(dir)) return null
84+
const found = readdirSync(dir)
85+
.map((name) => ({ name, version: bridgeVersionOf(name) }))
86+
.filter((x): x is { name: string; version: string } => !!x.version)
87+
.sort((a, b) => compareBridgeVersions(b.version, a.version))[0]
88+
return found ? { ...found, path: join(dir, found.name) } : null
89+
}
90+
91+
// ---- the newest published one ----
92+
93+
const RELEASES_URL = `https://api.github.com/repos/${BRIDGE_REPO}/releases?per_page=30`
94+
let releaseCache: { at: number; asset: BridgeAsset | null } | null = null
95+
const RELEASE_TTL_MS = 30 * 60_000
96+
97+
/**
98+
* The newest published jar, or null.
99+
*
100+
* Cached for half an hour including the null: a box with no internet is exactly
101+
* where a server manager runs, and asking GitHub again on every panel render
102+
* would spend a request per page load to learn the same thing.
103+
*/
104+
export async function latestBridge(force = false): Promise<BridgeAsset | null> {
105+
if (!force && releaseCache && Date.now() - releaseCache.at < RELEASE_TTL_MS) {
106+
return releaseCache.asset
107+
}
108+
let asset: BridgeAsset | null = null
109+
try {
110+
asset = pickBridgeAsset(await httpJson<GhRelease[]>(RELEASES_URL))
111+
} catch (e) {
112+
log.info('Bridge: release check failed (' + String(e) + '); the bundled jar still works')
113+
asset = null
114+
}
115+
releaseCache = { at: Date.now(), asset }
116+
return asset
117+
}
118+
119+
export function _resetBridgeCache(): void {
120+
releaseCache = null
121+
}
122+
123+
// ---- status ----
124+
125+
/**
126+
* What to say about one server.
127+
*
128+
* The published version and the bundled one are compared, and the newer wins:
129+
* an app that has been open for a month should not offer its own stale copy
130+
* when a newer one exists, and a fresh build should not report an "update" to a
131+
* jar older than the one it ships.
132+
*/
133+
export async function bridgeStatus(serverId: string): Promise<BridgeStatus> {
134+
const s = getServer(serverId)
135+
if (!s) throw new Error('server-not-found')
136+
if (!bridgeSupported(s.type)) {
137+
return { serverId, state: 'unsupported', actionable: false, source: null }
138+
}
139+
const installed = installedBridgeVersion(serverId)
140+
const remote = await latestBridge()
141+
const bundled = bundledBridge()
142+
const offline = remote === null
143+
144+
let source: 'github' | 'bundled' | null = null
145+
let latest: string | null = null
146+
if (remote && bundled) {
147+
const useRemote = compareBridgeVersions(remote.version, bundled.version) >= 0
148+
source = useRemote ? 'github' : 'bundled'
149+
latest = useRemote ? remote.version : bundled.version
150+
} else if (remote) {
151+
source = 'github'
152+
latest = remote.version
153+
} else if (bundled) {
154+
source = 'bundled'
155+
latest = bundled.version
156+
}
157+
158+
const need = bridgeNeed({ type: s.type, installed, latest })
159+
return { serverId, ...need, source: need.actionable ? source : null, ...(offline ? { offline: true } : {}) }
160+
}
161+
162+
// ---- install ----
163+
164+
/**
165+
* Put the newest bridge jar into `plugins/`, and take the old ones out.
166+
*
167+
* Removing the previous jars is not tidiness. Bukkit reads every jar in the
168+
* folder and refuses the second plugin with a name it has already loaded, so an
169+
* update that only added a file would leave the server logging a duplicate-name
170+
* error and running whichever copy it happened to read first — the exact
171+
* failure an operator would blame on the new version.
172+
*/
173+
export async function installBridge(
174+
serverId: string,
175+
who: { by: string; source: AuditSource }
176+
): Promise<BridgeInstallResult> {
177+
const s = getServer(serverId)
178+
if (!s) return { ok: false, error: 'server-not-found' }
179+
if (!bridgeSupported(s.type)) return refuse(serverId, who, 'unsupported-type')
180+
181+
const remote = await latestBridge()
182+
const bundled = bundledBridge()
183+
const preferRemote =
184+
!!remote && (!bundled || compareBridgeVersions(remote.version, bundled.version) >= 0)
185+
if (!remote && !bundled) return refuse(serverId, who, 'no-jar-available')
186+
187+
const dir = pluginsDir(serverId)
188+
mkdirSync(dir, { recursive: true })
189+
190+
let version: string
191+
let written: string
192+
let from: 'github' | 'bundled'
193+
194+
if (preferRemote && remote) {
195+
// Re-checked here and not only where the asset was picked. This is the last
196+
// line before a URL from a response body is handed to the downloader, and
197+
// the two checks are cheap next to what passing a bad one would cost.
198+
if (!isGithubAssetUrl(remote.url)) return refuse(serverId, who, 'bad-asset-url')
199+
if (!BRIDGE_JAR_RE.test(remote.name)) return refuse(serverId, who, 'bad-asset-name')
200+
written = join(dir, remote.name)
201+
try {
202+
await downloadFile(remote.url, written, {
203+
...(remote.sha256 ? { sha256: remote.sha256 } : {}),
204+
timeoutMs: 60_000
205+
})
206+
} catch (e) {
207+
rmSync(written, { force: true })
208+
return refuse(serverId, who, 'download-failed: ' + String(e))
209+
}
210+
version = remote.version
211+
from = 'github'
212+
} else if (bundled) {
213+
written = join(dir, bundled.name)
214+
copyFileSync(bundled.path, written)
215+
version = bundled.version
216+
from = 'bundled'
217+
} else {
218+
return refuse(serverId, who, 'no-jar-available')
219+
}
220+
221+
// Only after the new jar is on disk. Removing first would leave a server with
222+
// no bridge at all if the download failed halfway.
223+
const removed: string[] = []
224+
for (const old of installedJars(serverId)) {
225+
if (join(dir, old.name) === written) continue
226+
rmSync(join(dir, old.name), { force: true })
227+
removed.push(old.name)
228+
}
229+
230+
audit.record({
231+
source: who.source,
232+
action: 'bridge.install',
233+
actor: who.by,
234+
ok: true,
235+
serverId,
236+
target: version,
237+
detail: from + (removed.length ? ' (replaced ' + removed.join(', ') + ')' : '')
238+
})
239+
log.info(`Bridge: installed ${version} from ${from} for ${serverId}`)
240+
return { ok: true, version, source: from, removed }
241+
}
242+
243+
function refuse(
244+
serverId: string,
245+
who: { by: string; source: AuditSource },
246+
error: string
247+
): BridgeInstallResult {
248+
audit.record({
249+
source: who.source,
250+
action: 'bridge.install',
251+
actor: who.by,
252+
ok: false,
253+
serverId,
254+
detail: error
255+
})
256+
return { ok: false, error }
257+
}
258+
259+
/**
260+
* The sha256 of the bundled jar, for the smoke.
261+
*
262+
* Exported rather than computed at the call site so the test hashes the file
263+
* the installer actually copies, not one it located by repeating the lookup.
264+
*/
265+
export function bundledBridgeSha256(): string | null {
266+
const b = bundledBridge()
267+
return b ? createHash('sha256').update(readFileSync(b.path)).digest('hex') : null
268+
}

0 commit comments

Comments
 (0)