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
151 changes: 151 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
{
"name": "store"
},
{
"name": "areas"
},
{
"name": "site"
},
Expand Down Expand Up @@ -2838,6 +2841,154 @@
}
}
},
"/api/v1/servers/{id}/areas": {
"get": {
"operationId": "getServersIdAreas",
"summary": "Named chunk areas, including hidden ones.",
"description": "Scope `view` on the server.\n\nThe public map serves its own copy without the hidden areas or the timestamps.",
"tags": [
"areas"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Server id, as returned by GET /servers.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success."
},
"400": {
"description": "Malformed request, or a missing confirmation."
},
"401": {
"description": "No usable credential."
},
"403": {
"description": "Authenticated, but not permitted — the body names what was needed."
},
"404": {
"description": "No such server, or no such route."
},
"409": {
"description": "Conflicts with the current state (running server, name taken, …)."
},
"429": {
"description": "Rate limited. `Retry-After` says for how long."
}
}
},
"post": {
"operationId": "postServersIdAreas",
"summary": "Create an area, or edit one by passing `areaId`.",
"description": "Scope `settings` on the server.\n\nBody fields:\n- `areaId` — Omit to create; pass an existing id to replace that area.\n- `name` — Shown on hover and on click. 1-48 characters.\n- `note` — The second line of the tooltip, e.g. who owns the area. Up to 280 characters.\n- `colour` — `#rrggbb` (`#rgb` is expanded). Anything else falls back to the first palette colour.\n- `dim` — `overworld` | `nether` | `end`, or a modded key. An area belongs to exactly one.\n- `rects` — Array of `{x1,z1,x2,z2}` in CHUNK coordinates, inclusive, corners in any order.\n- `hidden` — Keep it off every map but the operator's own.\n\nRectangles are tidied on the way in: duplicates and contained rects are dropped and neighbours that share a full edge are merged, so the stored shape covers exactly the chunks you sent. Refusals name themselves: `name-required`, `name-too-long`, `note-too-long`, `no-chunks`, `too-many-chunks` (max 65536), `too-many-rects` (a shape of more than 64 separate pieces after tidying, or more than 1024 sent), `too-many-areas` (max 200), `area-not-found`. A shape too complex to store is refused, never silently trimmed.",
"tags": [
"areas"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Server id, as returned by GET /servers.",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"responses": {
"200": {
"description": "Success."
},
"400": {
"description": "Malformed request, or a missing confirmation."
},
"401": {
"description": "No usable credential."
},
"403": {
"description": "Authenticated, but not permitted — the body names what was needed."
},
"404": {
"description": "No such server, or no such route."
},
"409": {
"description": "Conflicts with the current state (running server, name taken, …)."
},
"429": {
"description": "Rate limited. `Retry-After` says for how long."
}
}
},
"delete": {
"operationId": "deleteServersIdAreas",
"summary": "Delete one area.",
"description": "Scope `settings` on the server.",
"tags": [
"areas"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Server id, as returned by GET /servers.",
"schema": {
"type": "string"
}
},
{
"name": "areaId",
"in": "query",
"required": true,
"description": "Area to delete.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success."
},
"400": {
"description": "Malformed request, or a missing confirmation."
},
"401": {
"description": "No usable credential."
},
"403": {
"description": "Authenticated, but not permitted — the body names what was needed."
},
"404": {
"description": "No such server, or no such route."
},
"409": {
"description": "Conflicts with the current state (running server, name taken, …)."
},
"429": {
"description": "Rate limited. `Retry-After` says for how long."
}
}
}
},
"/api/v1/servers/{id}/player-requests": {
"get": {
"operationId": "getServersIdPlayerRequests",
Expand Down
120 changes: 120 additions & 0 deletions src/main/core/chunkAreas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { dataDir } from '../paths'
import { log } from '../logger'
import { checkArea, publicChunkAreas, MAX_AREAS } from '@shared/chunkAreas'
import type { AreaInput, ChunkArea, PublicChunkArea } from '@shared/chunkAreas'

/**
* Where chunk areas live (#144).
*
* Their own file, keyed by server, rather than a field on `ServerConfig`. Two
* hundred areas of sixty-four rectangles is a lot of JSON to carry inside the
* record that is read on every server list, every status poll and every start —
* and areas change on a completely different schedule to the rest of it.
*
* All the rules live in `@shared/chunkAreas`; this file is the disk and the ids.
*/

type Store = Record<string, ChunkArea[]>

let store: Store = {}
let loaded = false

function storePath(): string {
return join(dataDir(), 'chunk-areas.json')
}

function load(): void {
if (loaded) return
loaded = true
try {
store = existsSync(storePath()) ? (JSON.parse(readFileSync(storePath(), 'utf-8')) as Store) : {}
} catch (e) {
log.warn('chunkAreas: could not read chunk-areas.json:', e)
store = {}
}
}

function save(): void {
const p = storePath()
const tmp = p + '.tmp'
writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8')
renameSync(tmp, p)
}

export function listAreas(serverId: string): ChunkArea[] {
load()
return store[serverId] ? [...store[serverId]] : []
}

/** What the public site and the map page are allowed to send a visitor. */
export function listPublicAreas(serverId: string, dim?: string): PublicChunkArea[] {
return publicChunkAreas(listAreas(serverId), dim)
}

export function createArea(serverId: string, input: AreaInput): ChunkArea {
load()
const check = checkArea(input)
if (!check.ok) throw new Error(check.error)
const list = store[serverId] ?? []
// A cap, because this list is sent to a public page and tested per chunk. The
// refusal is loud rather than silently dropping the newest one.
if (list.length >= MAX_AREAS) throw new Error('too-many-areas')
const now = Date.now()
const area: ChunkArea = { id: randomUUID(), ...check.value, createdAt: now, updatedAt: now }
store[serverId] = [...list, area]
save()
return area
}

/**
* Replace an area's contents. `createdAt` is kept and `updatedAt` moves, which
* is not bookkeeping: `updatedAt` is the tie-break when two areas of equal size
* cover one chunk, so an edit has to be visible to `areaAt`.
*/
export function updateArea(serverId: string, id: string, input: AreaInput): ChunkArea {
load()
const list = store[serverId] ?? []
const i = list.findIndex((a) => a.id === id)
if (i < 0) throw new Error('area-not-found')
const check = checkArea(input)
if (!check.ok) throw new Error(check.error)
const next: ChunkArea = {
...list[i],
...check.value,
// `checkArea` only sets `hidden` when it is true, so spreading it cannot
// clear the flag - an area unhidden through the API would stay hidden.
hidden: !!input.hidden,
updatedAt: Date.now()
}
if (!next.hidden) delete next.hidden
const copy = [...list]
copy[i] = next
store[serverId] = copy
save()
return next
}

export function deleteArea(serverId: string, id: string): void {
load()
const list = store[serverId] ?? []
if (!list.some((a) => a.id === id)) throw new Error('area-not-found')
store[serverId] = list.filter((a) => a.id !== id)
save()
}

/** Called when a server is forgotten, so its areas do not outlive it on disk. */
export function forgetServerAreas(serverId: string): void {
load()
if (!store[serverId]) return
delete store[serverId]
save()
}

/** Test seam: the smoke needs a clean slate without deleting the user's file. */
export function _reset(): void {
store = {}
loaded = true
}
5 changes: 5 additions & 0 deletions src/main/core/serverRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveBaseDir, dataDir } from '../paths'
import { detectServer } from './serverDetect'
import * as metrics from './metrics'
import * as events from './events'
import * as chunkAreas from './chunkAreas'
import { log } from '../logger'
import { PROXY_TYPES } from '@shared/types'
import type { ServerConfig, ServerType, JavaArgsConfig } from '@shared/types'
Expand Down Expand Up @@ -119,6 +120,10 @@ export function removeServer(id: string, deleteFiles: boolean): void {
})
metrics.dropServer(id)
events.dropServer(id)
// Areas are keyed by server id and live in their own file, so nothing else
// removes them. A later server issued the same id would inherit somebody
// else's map annotations.
chunkAreas.forgetServerAreas(id)
if (deleteFiles && target && existsSync(target.path)) {
try {
rmSync(target.path, { recursive: true, force: true })
Expand Down
31 changes: 31 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import * as rcon from '../core/rcon'
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 { areaChunkCount } from '@shared/chunkAreas'
import type { AreaInput } from '@shared/chunkAreas'
import * as backups from '../core/backups'
import * as worlds from '../core/worlds'
import * as scheduler from '../core/scheduler'
Expand Down Expand Up @@ -477,6 +480,34 @@ export function registerIpc(): void {
})
return k
})
// 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))
H(IPC.areasSave, (_e, serverId: string, input: AreaInput & { areaId?: string }) => {
const area = input.areaId
? chunkAreas.updateArea(serverId, input.areaId, input)
: chunkAreas.createArea(serverId, input)
audit.record({
source: 'panel',
action: input.areaId ? 'area.update' : 'area.create',
actor: 'operator',
target: area.name,
detail: area.dim + ' ' + areaChunkCount(area) + ' chunks',
serverId
})
return area
})
H(IPC.areasDelete, (_e, serverId: string, areaId: string) => {
const gone = chunkAreas.listAreas(serverId).find((a) => a.id === areaId)
chunkAreas.deleteArea(serverId, areaId)
audit.record({
source: 'panel',
action: 'area.delete',
actor: 'operator',
target: gone?.name ?? areaId,
serverId
})
})
H(IPC.apiKeyRevoke, (_e, keyId: string) => {
const k = apikeys.revokeKey(keyId)
audit.record({ source: 'panel', action: 'apikey.revoke', actor: 'operator', target: k.label })
Expand Down
Loading
Loading