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
233 changes: 233 additions & 0 deletions src/main/core/clientAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import AdmZip from 'adm-zip'
import { cacheDir } from '../paths'
import { log } from '../logger'
import { httpJson, downloadFile } from './net'
import { textureCandidates, wantsTexture, textureKey, assetVersion } from '@shared/textures'
import type { AssetStatus } from '@shared/textures'

/**
* Item and block textures, taken from Mojang's own client jar (#127).
*
* The app used to hot-link `assets.mcasset.cloud` for every item icon. That is a
* third party in the middle of a private server's inventory, it tells them which
* items an operator is looking at, and on an air-gapped LAN — a normal place to
* run this — it renders a grid of broken images.
*
* Mojang publishes the client jar for every version and MSMS already downloads
* and sha1-verifies jars from that same manifest, so this needs no new trust and
* no new dependency. One download per Minecraft version, shared by every server
* on it.
*
* Nothing here is required for the app to work: with no assets downloaded, the
* lookup answers null and the caller draws what it drew before.
*/

const MANIFEST = 'https://launchermeta.mojang.com/mc/game/version_manifest_v2.json'

interface Manifest {
versions: { id: string; type: string; url: string }[]
}
interface VersionDetail {
downloads?: { client?: { url: string; sha1: string; size?: number } }
}

/** `msms-data/cache/assets/<version>/`. Beside the other per-version data. */
function versionDir(version: string): string {
return join(cacheDir(), 'assets', version)
}

function indexPath(version: string): string {
return join(versionDir(version), 'index.json')
}

/**
* In-flight downloads, keyed by version.
*
* Two servers on the same version opening a profile at once must not start two
* 28 MB downloads. The promise is shared, so the second caller waits for the
* first rather than racing it into the same directory.
*/
const inFlight = new Map<string, Promise<AssetStatus>>()

function readIndex(version: string): string[] {
try {
const p = indexPath(version)
if (!existsSync(p)) return []
const j = JSON.parse(readFileSync(p, 'utf-8')) as { keys?: string[] }
return Array.isArray(j.keys) ? j.keys : []
} catch {
return []
}
}

function dirSizeMB(dir: string): number {
let n = 0
try {
for (const f of readdirSync(dir)) {
try {
n += statSync(join(dir, f)).size
} catch {
// A file that vanished between listing and stat is not worth failing a
// size report over.
}
}
} catch {
return 0
}
return Math.round((n / 1048576) * 10) / 10
}

export function assetStatus(mcVersion: string): AssetStatus {
const version = assetVersion(mcVersion)
if (!version) return { version: '', ready: false, count: 0, busy: false, sizeMB: 0 }
const keys = readIndex(version)
return {
version,
ready: keys.length > 0,
count: keys.length,
busy: inFlight.has(version),
sizeMB: keys.length ? dirSizeMB(versionDir(version)) : 0
}
}

/**
* One texture's PNG bytes, or null.
*
* Never downloads. A lookup that could start a 28 MB fetch would make drawing an
* inventory unpredictably slow, and this is called once per slot; fetching is
* something the operator asks for, once, in the UI.
*/
export function itemTexture(mcVersion: string, id: string): Buffer | null {
const version = assetVersion(mcVersion)
if (!version) return null
const dir = versionDir(version)
for (const cand of textureCandidates(id)) {
// `textureCandidates` only ever returns `item/x` or `block/x` with `x`
// matched against [a-z0-9_], so this cannot leave the directory.
const p = join(dir, cand.replace('/', '__') + '.png')
try {
if (existsSync(p)) return readFileSync(p)
} catch {
// Unreadable file: try the next candidate rather than failing the row.
}
}
return null
}

/** Many at once, so drawing an inventory is one call rather than forty. */
export function itemTextures(mcVersion: string, ids: string[]): Record<string, string> {
const out: Record<string, string> = {}
for (const id of ids.slice(0, 256)) {
if (out[id] !== undefined) continue
const png = itemTexture(mcVersion, id)
if (png) out[id] = 'data:image/png;base64,' + png.toString('base64')
}
return out
}

async function resolveClientJar(version: string): Promise<{ url: string; sha1: string }> {
const m = await httpJson<Manifest>(MANIFEST)
const entry = m.versions.find((v) => v.id === version)
if (!entry) throw new Error('unknown-version')
const detail = await httpJson<VersionDetail>(entry.url)
const dl = detail.downloads?.client
// `downloads.client`, not `.server` — the same object carries both, and the
// server jar has no assets in it at all.
if (!dl?.url || !dl.sha1) throw new Error('no-client-jar-for-version')
return { url: dl.url, sha1: dl.sha1 }
}

/**
* Download the client jar for this version and extract the two texture folders.
*
* Idempotent: with the textures already on disk it returns immediately without
* touching the network, which is what makes it safe to call from a button an
* operator may press twice.
*/
export async function ensureClientAssets(
mcVersion: string,
onProgress?: (pct: number, note: string) => void
): Promise<AssetStatus> {
const version = assetVersion(mcVersion)
if (!version) throw new Error('unknown-version')
const have = assetStatus(version)
if (have.ready) return have
const running = inFlight.get(version)
if (running) return running

const task = (async (): Promise<AssetStatus> => {
const dir = versionDir(version)
mkdirSync(dir, { recursive: true })
// Staged outside the cache: a jar half-written into the assets directory
// would be indistinguishable from a finished one on the next start.
const jar = join(tmpdir(), `msms-client-${version}-${Date.now()}.jar`)
try {
onProgress?.(0, 'resolving')
const { url, sha1 } = await resolveClientJar(version)
onProgress?.(2, 'downloading')
await downloadFile(url, jar, {
sha1,
timeoutMs: 120_000,
// 0-95% is the download; extraction is the rest. A bar that sits at 100
// while a jar is still being unpacked is a bar that looks stuck.
onProgress: (got, total) =>
onProgress?.(total ? 2 + Math.round((got / total) * 93) : 50, 'downloading')
})
onProgress?.(96, 'extracting')
const zip = new AdmZip(jar)
const keys: string[] = []
for (const e of zip.getEntries()) {
if (e.isDirectory) continue
const name = e.entryName.replace(/\\/g, '/')
if (!wantsTexture(name)) continue
const key = textureKey(name)
if (!key) continue
// Flattened with a separator that cannot occur in a texture name, so the
// directory stays one level deep and no entry name from the archive is
// ever used as a path.
writeFileSync(join(dir, key.replace('/', '__') + '.png'), e.getData())
keys.push(key)
}
if (!keys.length) throw new Error('no-textures-in-jar')
writeFileSync(indexPath(version), JSON.stringify({ version, keys, at: Date.now() }), 'utf-8')
log.info(`Client assets: extracted ${keys.length} textures for ${version}`)
onProgress?.(100, 'done')
return assetStatus(version)
} catch (e) {
// A failed extraction must not leave a directory that looks half-ready.
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// Best effort; the missing index is what makes it not-ready anyway.
}
throw e
} finally {
try {
rmSync(jar, { force: true })
} catch {
// A leftover temp jar is untidy, not broken.
}
inFlight.delete(version)
}
})()

inFlight.set(version, task)
return task
}

/** Drop one version's textures. Returns how many files went. */
export function clearClientAssets(mcVersion: string): number {
const version = assetVersion(mcVersion)
if (!version) return 0
const dir = versionDir(version)
const n = readIndex(version).length
try {
rmSync(dir, { recursive: true, force: true })
} catch {
return 0
}
return n
}
7 changes: 7 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as mods from '../core/mods'
import * as bridgeInstall from '../core/bridgeInstall'
import * as worldTiles from '../core/worldTiles'
import * as chunkAreas from '../core/chunkAreas'
import * as clientAssets from '../core/clientAssets'
import { areaChunkCount } from '@shared/chunkAreas'
import { normalizeMapPage } from '@shared/mapPage'
import type { AreaInput } from '@shared/chunkAreas'
Expand Down Expand Up @@ -491,6 +492,12 @@ export function registerIpc(): void {
})
return k
})
// Item textures from the client jar (#127).
H(IPC.assetsStatus, (_e, v: string) => clientAssets.assetStatus(v))
H(IPC.assetsEnsure, (_e, v: string) => clientAssets.ensureClientAssets(v))
H(IPC.assetsTextures, (_e, v: string, ids: string[]) =>
clientAssets.itemTextures(v, Array.isArray(ids) ? ids : [])
)
// Named chunk areas (#144). The rules and the store are shared with the HTTP
// routes — this is the same call the panel makes, reached a different way.
H(IPC.areasList, (_e, serverId: string) => chunkAreas.listAreas(serverId))
Expand Down
95 changes: 94 additions & 1 deletion src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ import * as alertsMod from './core/alerts'
import * as worldsMod from './core/worlds'
import * as areasMod from '@shared/chunkAreas'
import * as areasMod2 from './core/chunkAreas'
import * as tex from '@shared/textures'
import * as assetsMod from './core/clientAssets'
import {
isValidMcName,
isValidWorldName,
Expand Down Expand Up @@ -189,7 +191,7 @@ import { aggregateJoins, type JoinRecord } from '@shared/joins'
import * as auditMod from './core/audit'
import type { UptimeReport } from '@shared/uptime'
import type { JavaArgsConfig, MetricSeries, ServerConfig, ServerEvent, ServerType } from '@shared/types'
import { alertsPath, uploadsDir, auditDir, dataDir } from './paths'
import { alertsPath, uploadsDir, auditDir, dataDir, cacheDir } from './paths'
import { analyzeCrash } from './core/crash'
import { CREATABLE_TYPES, createErrorKey } from '@shared/versions'

Expand Down Expand Up @@ -2231,6 +2233,97 @@ 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')

// --- 12b. item textures from the client jar (#127) ----------------------
{
// Which paths are worth trying. The old CDN only ever asked for
// `item/<id>.png` and then `block/<id>.png`, which is why a chest, a log
// or a grass block fell through to a three-letter text chip.
const cands = tex.textureCandidates('oak_log')
if (cands[0] !== 'item/oak_log') return fail('an item is not tried first: ' + cands[0])
if (!cands.includes('block/oak_log')) return fail('the block folder is not tried')
if (!cands.includes('block/oak_log_top')) return fail('the top face is not tried')
// grass_block has no block/grass_block.png at all — only _top and _side.
if (!tex.textureCandidates('grass_block').includes('block/grass_block_side')) {
return fail('grass_block would still find nothing')
}
// A namespaced id is the same item.
if (tex.textureCandidates('minecraft:apple')[0] !== 'item/apple') return fail('the namespace was not stripped')
// An id from hand-edited NBT must not become a path. This is the only
// thing between a crafted inventory entry and the cache directory.
for (const bad of ['../../etc/passwd', 'a/b', '..', '', 'x'.repeat(80), 'Apple!', 'a b']) {
const c = tex.textureCandidates(bad)
if (c.length) return fail('an unsafe id produced candidates: ' + JSON.stringify(bad))
}

// What the extractor keeps. The client jar holds thousands of files and
// only two folders are ever looked up.
if (!tex.wantsTexture('assets/minecraft/textures/item/apple.png')) return fail('an item texture was skipped')
if (!tex.wantsTexture('assets/minecraft/textures/block/stone.png')) return fail('a block texture was skipped')
for (const skip of [
'assets/minecraft/textures/entity/creeper/creeper.png',
'assets/minecraft/textures/item/apple.png.mcmeta',
'assets/minecraft/lang/en_us.json',
'../../../evil.png',
'assets/minecraft/textures/item/sub/dir.png'
]) {
if (tex.wantsTexture(skip)) return fail('the extractor would keep ' + skip)
}
if (tex.textureKey('assets/minecraft/textures/block/stone.png') !== 'block/stone') {
return fail('the texture key is wrong')
}

// Which version's jar. Snapshots and modded strings reduce to the release
// they are built on; there is no point downloading twenty snapshots to
// get the same apple.
for (const [given, want] of [
['1.21.4', '1.21.4'],
['1.21', '1.21'],
['1.21.4-pre2', '1.21.4'],
['1.20.1-forge-47.2.0', '1.20.1'],
['nonsense', ''],
['', '']
] as const) {
if (tex.assetVersion(given) !== want) {
return fail('assetVersion(' + JSON.stringify(given) + ') = ' + tex.assetVersion(given))
}
}

// ...and the round trip, without the network: write a texture where the
// extractor would have put it and check the lookup finds it through the
// candidate list rather than only by an exact name.
const av = tex.assetVersion('1.21.4')
const adir = join(cacheDir(), 'assets', av)
const hadIndex = existsSync(join(adir, 'index.json'))
if (!hadIndex) {
mkdirSync(adir, { recursive: true })
// A one-pixel PNG. Only the bytes coming back matter here.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
)
writeFileSync(join(adir, 'block__grass_block_side.png'), png)
writeFileSync(join(adir, 'item__apple.png'), png)
writeFileSync(join(adir, 'index.json'), JSON.stringify({ version: av, keys: ['item/apple'] }))
try {
if (!assetsMod.itemTexture('1.21.4', 'apple')) return fail('a written item texture was not found')
// The case the CDN got wrong: found by suffix, not by exact name.
if (!assetsMod.itemTexture('1.21.4', 'grass_block')) return fail('grass_block was not found by alias')
if (assetsMod.itemTexture('1.21.4', 'nonexistent_thing')) return fail('a missing texture returned bytes')
if (assetsMod.itemTexture('1.21.4', '../../evil')) return fail('an unsafe id reached the disk')
const many = assetsMod.itemTextures('1.21.4', ['apple', 'apple', 'nonexistent_thing'])
if (!many['apple']?.startsWith('data:image/png;base64,')) return fail('the batch lookup returned no data url')
if (many['nonexistent_thing']) return fail('the batch lookup invented a texture')
if (!assetsMod.assetStatus('1.21.4').ready) return fail('the status does not see the index')
// An unknown version is not ready and does not throw — an operator on
// a modded string must still get an app, not an exception.
if (assetsMod.assetStatus('nonsense').ready) return fail('an unknown version claimed to be ready')
} finally {
rmSync(adir, { recursive: true, force: true })
}
}
console.log('WORLDS-SMOKE: client-jar textures OK (candidates, extractor filter, safe ids, local lookup)')
}

// --- 12. chunk areas: the rules four map surfaces have to share (#144) ---
{
const at = (rs: number[][]): { x1: number; z1: number; x2: number; z2: number }[] =>
Expand Down
3 changes: 3 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ const api: MsmsApi = {
mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks),
clearMapCache: () => ipcRenderer.invoke(IPC.mapCacheClear),
setApiKeyDisabled: (id, disabled) => ipcRenderer.invoke(IPC.apiKeyDisabled, id, disabled),
assetStatus: (v) => ipcRenderer.invoke(IPC.assetsStatus, v),
ensureAssets: (v) => ipcRenderer.invoke(IPC.assetsEnsure, v),
itemTextures: (v, ids) => ipcRenderer.invoke(IPC.assetsTextures, v, ids),
listChunkAreas: (serverId) => ipcRenderer.invoke(IPC.areasList, serverId),
saveChunkArea: (serverId, input) => ipcRenderer.invoke(IPC.areasSave, serverId, input),
deleteChunkArea: (serverId, areaId) => ipcRenderer.invoke(IPC.areasDelete, serverId, areaId),
Expand Down
Loading
Loading