From d9fd79e4059c44e7974ca23ac865d276e159dbd8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 24 Apr 2026 08:38:43 -0700 Subject: [PATCH 01/20] Add Blueprints catalog tab backed by weblink repo Periodically git-clones BentoBoxWorld/weblink to data/weblink, parses .blueprint and bundle .json files, derives per-blueprint stats (dimensions, block/entity counts, biome list, sinking flag) and serves them via new /api/blueprints, /api/blueprints/download and /api/blueprints/zip endpoints. Front-end adds a Blueprints tab with game-mode filter, per-card download, and multi-select zip download. The optional blueprints/catalog.json overlay in weblink supplies curation fields (displayName, description, author, tags, license, image) that take precedence over whatever is in the .blueprint file. Phase-2 submission sanitisers (strip BlueprintBundle.commands; reject command-block materials and opaque inventory payloads) are included as helpers for when the upload flow lands. Co-Authored-By: Claude Opus 4.7 --- env.example.json | 7 +- src/api/api.ts | 156 +++++++++++++ src/api/blueprintCatalog.ts | 370 ++++++++++++++++++++++++++++++ src/api/weblink.ts | 75 ++++++ src/config.d.ts | 64 ++++++ src/web/ApiRequestManager.ts | 18 +- src/web/components/Blueprints.tsx | 317 +++++++++++++++++++++++++ src/web/components/ContentBox.tsx | 20 +- src/web/components/Navigation.tsx | 16 +- 9 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 src/api/blueprintCatalog.ts create mode 100644 src/api/weblink.ts create mode 100644 src/web/components/Blueprints.tsx diff --git a/env.example.json b/env.example.json index 01aa66c..72ad8d3 100644 --- a/env.example.json +++ b/env.example.json @@ -2,5 +2,10 @@ "github_token": "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "github_downloads": 2, "discord_error_webhook_url": "/api/webhooks/xxxxxxxxxxxxxxxxxxxxxxxxxxx", - "port": 8080 + "port": 8080, + "weblink": { + "url": "https://github.com/BentoBoxWorld/weblink.git", + "path": "./../data/weblink", + "branch": "master" + } } diff --git a/src/api/api.ts b/src/api/api.ts index 07d58b8..4102734 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,7 +13,15 @@ import { Sequelize } from 'sequelize'; import { JenkinsAPI } from 'jenkins'; import axios from 'axios'; import * as fs from 'fs'; +import * as path from 'path'; import Archiver = require('archiver'); +import { WeblinkSync } from './weblink'; +import { + BlueprintCatalog, + buildCatalog, + parseBlueprintId, + resolveBlueprintFile, +} from './blueprintCatalog'; const { throttling } = require('@octokit/plugin-throttling'); @@ -25,6 +33,15 @@ export default class ApiManager { tutorial = fs.readFileSync('../Installation-Guide.txt'); thirdParty: ThirdParty = JSON.parse(fs.readFileSync('./../thirdparty.json').toString()); + weblink: WeblinkSync = this.createWeblinkSync(); + blueprintCatalog: BlueprintCatalog = { + blueprints: [], + bundles: [], + tags: {}, + gameModes: {}, + generatedAt: 0, + }; + jarSequelize = new Sequelize('database', 'user', 'password', { host: 'localhost', dialect: 'sqlite', @@ -100,6 +117,7 @@ export default class ApiManager { } cron.schedule('*/6 * * * *', () => { this.generateDownloads(); + this.refreshBlueprints(); if (undefinedAddons.length > 0) { for (let i = 0; i < (undefinedAddons.length > repeats ? repeats : undefinedAddons.length); i++) { this.updateAsset(undefinedAddons[0]).then(); @@ -157,6 +175,26 @@ export default class ApiManager { }; setAddons(); + + this.refreshBlueprints(); + } + + private createWeblinkSync(): WeblinkSync { + let overrides: Partial<{ url: string; path: string; branch: string }> = {}; + try { + const env = require('./../../env.json'); + if (env && env.weblink) overrides = env.weblink; + } catch (e) {} + return new WeblinkSync(overrides); + } + + async refreshBlueprints() { + try { + await this.weblink.sync(); + this.blueprintCatalog = buildCatalog(this.weblink.blueprintsDir); + } catch (err) { + console.error('[blueprints] refresh failed:', (err as Error).message); + } } async updateJenkins(addon: AddonsEntity) { @@ -311,6 +349,17 @@ export default class ApiManager { res.send(this.thirdParty); res.end(); break; + case 'blueprints': + res.setHeader('Content-Type', 'application/json'); + res.send(this.blueprintCatalog); + res.end(); + break; + case 'blueprints/download': + this.downloadBlueprintFile(req, res); + break; + case 'blueprints/zip': + this.downloadBlueprintZip(req, res).then(); + break; case 'generate': this.generateZIP(req, res).then(); break; @@ -467,6 +516,113 @@ export default class ApiManager { } } + downloadBlueprintFile(req: Request, res: Response) { + const id = typeof req.query.id === 'string' ? req.query.id : ''; + const type = req.query.type === 'bundle' ? 'bundle' : 'blueprint'; + const ext = type === 'bundle' ? '.json' : '.blueprint'; + const abs = resolveBlueprintFile(this.weblink.blueprintsDir, id, ext); + if (!abs) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 404; + res.send({ Error: 404, Reason: 'Unknown blueprint' }); + res.end(); + return; + } + const filename = path.basename(abs); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + fs.createReadStream(abs).pipe(res); + } + + async downloadBlueprintZip(req: Request, res: Response) { + const idsParam = typeof req.query.ids === 'string' ? req.query.ids : ''; + const gameMode = typeof req.query.gameMode === 'string' ? req.query.gameMode : ''; + const entries: Array<{ abs: string; name: string }> = []; + + if (gameMode) { + const parsed = parseBlueprintId(`${gameMode}/stub`); + if (!parsed) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 400; + res.send({ Error: 400, Reason: 'Invalid gameMode' }); + res.end(); + return; + } + for (const bp of this.blueprintCatalog.blueprints) { + if (bp.gameMode === gameMode) { + const abs = path.resolve(this.weblink.blueprintsDir, bp.file); + entries.push({ abs, name: bp.file }); + } + } + for (const bd of this.blueprintCatalog.bundles) { + if (bd.gameMode === gameMode) { + const abs = path.resolve(this.weblink.blueprintsDir, bd.file); + entries.push({ abs, name: bd.file }); + } + } + } else if (idsParam) { + let ids: unknown; + try { + ids = JSON.parse(decodeURIComponent(idsParam)); + } catch { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 400; + res.send({ Error: 400, Reason: 'Not Valid Json' }); + res.end(); + return; + } + if (!Array.isArray(ids) || ids.length === 0) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 400; + res.send({ Error: 400, Reason: 'No Blueprints Selected' }); + res.end(); + return; + } + for (const id of ids) { + if (typeof id !== 'string') continue; + const abs = resolveBlueprintFile(this.weblink.blueprintsDir, id, '.blueprint'); + if (abs) { + const parsed = parseBlueprintId(id); + if (parsed) entries.push({ abs, name: `${parsed.gameMode}/${parsed.name}.blueprint` }); + continue; + } + const absBundle = resolveBlueprintFile(this.weblink.blueprintsDir, id, '.json'); + if (absBundle) { + const parsed = parseBlueprintId(id); + if (parsed) entries.push({ abs: absBundle, name: `${parsed.gameMode}/${parsed.name}.json` }); + } + } + } else { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 400; + res.send({ Error: 400, Reason: 'Provide ids or gameMode' }); + res.end(); + return; + } + + if (entries.length === 0) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 404; + res.send({ Error: 404, Reason: 'No matching blueprints' }); + res.end(); + return; + } + + const archive = Archiver('zip'); + archive.on('error', (err) => { + res.status(500).send({ error: err.message }); + }); + archive.on('end', () => res.end()); + + const zipName = gameMode ? `blueprints-${gameMode}.zip` : 'blueprints.zip'; + res.attachment(zipName); + archive.pipe(res); + for (const e of entries) { + archive.file(e.abs, { name: e.name }); + } + await archive.finalize(); + } + async updateDownloadCount(addon: string) { const addonDatabase: DownloadCountModel | null = await this.downloadCount.findOne({ where: { name: addon }, diff --git a/src/api/blueprintCatalog.ts b/src/api/blueprintCatalog.ts new file mode 100644 index 0000000..fac346b --- /dev/null +++ b/src/api/blueprintCatalog.ts @@ -0,0 +1,370 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +// ----- Catalog overlay (optional, in weblink/blueprints/catalog.json) ----- + +export interface CatalogOverlayEntry { + displayName?: string; + description?: string[]; + author?: string; + authorLink?: string; + tags?: string[]; + license?: string; + image?: string; +} + +export interface CatalogTag { + color: string; + description?: string; +} + +export interface CatalogGameMode { + displayName?: string; + description?: string; + color?: string; +} + +export interface CatalogOverlay { + blueprints?: Record; + bundles?: Record; + tags?: Record; + gameModes?: Record; +} + +// ----- Derived stats ----- + +export interface BlueprintStats { + dimensions: { x: number; y: number; z: number }; + blockCount: number; + attachedCount: number; + entityCount: number; + airCount: number; + biomes: string[]; + sinking: boolean; + topBlocks: Array<{ material: string; count: number }>; + topEntities: Array<{ type: string; count: number }>; +} + +export interface BlueprintEntry { + id: string; // "/" + gameMode: string; + name: string; // filename stem + displayName: string; + description: string[]; + icon: string; + file: string; // relative path inside weblink/blueprints + sizeBytes: number; + stats: BlueprintStats; + author?: string; + authorLink?: string; + tags?: string[]; + license?: string; + image?: string; // relative path to a PNG if overlay provided one +} + +export interface BundleEntry { + id: string; // "/" + gameMode: string; + uniqueId: string; + displayName: string; + description: string[]; + icon: string; + file: string; + blueprints: Record; // environment -> blueprint name + requirePermission?: boolean; + cost?: number; + times?: number; + author?: string; + tags?: string[]; +} + +export interface BlueprintCatalog { + blueprints: BlueprintEntry[]; + bundles: BundleEntry[]; + tags: Record; + gameModes: Record; + generatedAt: number; +} + +// ----- ID / path safety ----- + +const ID_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; + +export function parseBlueprintId(id: string): { gameMode: string; name: string } | null { + const parts = id.split('/'); + if (parts.length !== 2) return null; + const [gameMode, name] = parts; + if (!ID_SEGMENT.test(gameMode) || !ID_SEGMENT.test(name)) return null; + return { gameMode, name }; +} + +export function resolveBlueprintFile(blueprintsDir: string, id: string, ext: '.blueprint' | '.json'): string | null { + const parsed = parseBlueprintId(id); + if (!parsed) return null; + const target = path.resolve(blueprintsDir, parsed.gameMode, parsed.name + ext); + const root = path.resolve(blueprintsDir); + if (!target.startsWith(root + path.sep)) return null; + if (!fs.existsSync(target)) return null; + return target; +} + +// ----- .blueprint schema (subset) ----- + +type Vector = [number, number, number]; +type BlueprintBlock = { blockData: string; biome?: string }; +type BlueprintEntity = { type: string }; +type VectorKeyedBlockMap = Array<[Vector, BlueprintBlock]>; +type VectorKeyedEntityListMap = Array<[Vector, BlueprintEntity[]]>; + +interface BlueprintJson { + name?: string; + displayName?: string; + icon?: string; + description?: string[]; + xSize?: number; + ySize?: number; + zSize?: number; + sink?: boolean; + blocks?: VectorKeyedBlockMap; + attached?: VectorKeyedBlockMap; + entities?: VectorKeyedEntityListMap; +} + +interface BundleJson { + uniqueId?: string; + displayName?: string; + icon?: string; + description?: string[]; + blueprints?: Record; + requirePermission?: boolean; + slot?: number; + times?: number; + cost?: number; +} + +function materialOf(blockData: string): string { + const bracket = blockData.indexOf('['); + return bracket === -1 ? blockData : blockData.substring(0, bracket); +} + +function topN( + counts: Map, + n: number, + factory: (key: string, count: number) => T, +): T[] { + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([k, v]) => factory(k, v)); +} + +export function computeStats(bp: BlueprintJson): BlueprintStats { + const blocks = Array.isArray(bp.blocks) ? bp.blocks : []; + const attached = Array.isArray(bp.attached) ? bp.attached : []; + const entities = Array.isArray(bp.entities) ? bp.entities : []; + + const blockCounts = new Map(); + const biomes = new Set(); + let airCount = 0; + + for (const pair of blocks) { + const block = pair[1]; + if (!block || typeof block.blockData !== 'string') continue; + const mat = materialOf(block.blockData); + blockCounts.set(mat, (blockCounts.get(mat) || 0) + 1); + if (mat === 'minecraft:air' || mat === 'AIR') airCount++; + if (block.biome) biomes.add(block.biome); + } + for (const pair of attached) { + const block = pair[1]; + if (!block || typeof block.blockData !== 'string') continue; + const mat = materialOf(block.blockData); + blockCounts.set(mat, (blockCounts.get(mat) || 0) + 1); + if (block.biome) biomes.add(block.biome); + } + + const entityCounts = new Map(); + let entityTotal = 0; + for (const pair of entities) { + const list = pair[1]; + if (!Array.isArray(list)) continue; + for (const e of list) { + if (!e || typeof e.type !== 'string') continue; + entityCounts.set(e.type, (entityCounts.get(e.type) || 0) + 1); + entityTotal++; + } + } + + return { + dimensions: { + x: typeof bp.xSize === 'number' ? bp.xSize : 0, + y: typeof bp.ySize === 'number' ? bp.ySize : 0, + z: typeof bp.zSize === 'number' ? bp.zSize : 0, + }, + blockCount: blocks.length, + attachedCount: attached.length, + entityCount: entityTotal, + airCount, + biomes: Array.from(biomes).sort(), + sinking: bp.sink === true, + topBlocks: topN(blockCounts, 5, (material, count) => ({ material, count })), + topEntities: topN(entityCounts, 5, (type, count) => ({ type, count })), + }; +} + +// ----- Catalog build ----- + +function readOverlay(blueprintsDir: string): CatalogOverlay { + const file = path.join(blueprintsDir, 'catalog.json'); + if (!fs.existsSync(file)) return {}; + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch (err) { + console.error('[blueprints] bad catalog.json:', (err as Error).message); + return {}; + } +} + +function listGameModeDirs(blueprintsDir: string): string[] { + if (!fs.existsSync(blueprintsDir)) return []; + return fs + .readdirSync(blueprintsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +export function buildCatalog(blueprintsDir: string): BlueprintCatalog { + const overlay = readOverlay(blueprintsDir); + const blueprints: BlueprintEntry[] = []; + const bundles: BundleEntry[] = []; + + for (const gameMode of listGameModeDirs(blueprintsDir)) { + const gmDir = path.join(blueprintsDir, gameMode); + const files = fs.readdirSync(gmDir); + for (const file of files) { + const abs = path.join(gmDir, file); + const stat = fs.statSync(abs); + if (!stat.isFile()) continue; + + if (file.endsWith('.blueprint')) { + try { + const json: BlueprintJson = JSON.parse(fs.readFileSync(abs, 'utf-8')); + const name = file.slice(0, -'.blueprint'.length); + const id = `${gameMode}/${name}`; + const o = overlay.blueprints?.[id] ?? {}; + blueprints.push({ + id, + gameMode, + name, + displayName: o.displayName || json.displayName || json.name || name, + description: o.description ?? (Array.isArray(json.description) ? json.description : []), + icon: json.icon || 'PAPER', + file: path.posix.join(gameMode, file), + sizeBytes: stat.size, + stats: computeStats(json), + author: o.author, + authorLink: o.authorLink, + tags: o.tags, + license: o.license, + image: o.image, + }); + } catch (err) { + console.error(`[blueprints] failed to parse ${abs}:`, (err as Error).message); + } + } else if (file.endsWith('.json') && file !== 'catalog.json') { + try { + const json: BundleJson = JSON.parse(fs.readFileSync(abs, 'utf-8')); + if (!json.uniqueId) continue; // not a bundle + const uniqueId = json.uniqueId; + const id = `${gameMode}/${uniqueId}`; + const o = overlay.bundles?.[id] ?? {}; + bundles.push({ + id, + gameMode, + uniqueId, + displayName: o.displayName || json.displayName || uniqueId, + description: o.description ?? (Array.isArray(json.description) ? json.description : []), + icon: json.icon || 'PAPER', + file: path.posix.join(gameMode, file), + blueprints: json.blueprints || {}, + requirePermission: json.requirePermission, + cost: json.cost, + times: json.times, + author: o.author, + tags: o.tags, + }); + } catch (err) { + console.error(`[blueprints] failed to parse bundle ${abs}:`, (err as Error).message); + } + } + } + } + + blueprints.sort((a, b) => a.id.localeCompare(b.id)); + bundles.sort((a, b) => a.id.localeCompare(b.id)); + + return { + blueprints, + bundles, + tags: overlay.tags ?? {}, + gameModes: overlay.gameModes ?? {}, + generatedAt: Date.now(), + }; +} + +// ----- Submission sanitisers (Phase 2) ----- +// +// These are called on user-submitted content before it is written to weblink. +// The Phase 1 read-only catalog pulls from a trusted git repo and does not +// invoke these; they exist here because the dangerous fields live in the +// schema, so the rule belongs next to the code that understands it. + +const COMMAND_BLOCK_MATERIALS = new Set([ + 'minecraft:command_block', + 'minecraft:chain_command_block', + 'minecraft:repeating_command_block', + 'minecraft:jigsaw', + 'minecraft:structure_block', +]); + +export interface SubmissionIssue { + field: string; + reason: string; +} + +export function sanitizeBundleSubmission(raw: unknown): { bundle: BundleJson; stripped: SubmissionIssue[] } { + const src = (raw ?? {}) as BundleJson & { commands?: unknown }; + const stripped: SubmissionIssue[] = []; + const bundle: BundleJson = { ...src }; + if ('commands' in bundle) { + delete (bundle as { commands?: unknown }).commands; + stripped.push({ field: 'commands', reason: 'Console command execution is not permitted in submissions.' }); + } + return { bundle, stripped }; +} + +export function validateBlueprintSubmission(raw: unknown): { ok: boolean; issues: SubmissionIssue[] } { + const bp = (raw ?? {}) as BlueprintJson; + const issues: SubmissionIssue[] = []; + const blocks = Array.isArray(bp.blocks) ? bp.blocks : []; + const attached = Array.isArray(bp.attached) ? bp.attached : []; + for (const list of [blocks, attached]) { + for (const pair of list) { + const block = pair?.[1] as BlueprintBlock & { inventory?: unknown } | undefined; + if (!block) continue; + if (typeof block.blockData === 'string') { + const mat = materialOf(block.blockData); + if (COMMAND_BLOCK_MATERIALS.has(mat)) { + issues.push({ field: 'blocks', reason: `Disallowed material: ${mat}` }); + } + } + if ('inventory' in block && block.inventory) { + issues.push({ + field: 'blocks.inventory', + reason: 'Container contents are not permitted in submissions (opaque YAML payload).', + }); + } + } + } + return { ok: issues.length === 0, issues }; +} diff --git a/src/api/weblink.ts b/src/api/weblink.ts new file mode 100644 index 0000000..f693b1c --- /dev/null +++ b/src/api/weblink.ts @@ -0,0 +1,75 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFileP = promisify(execFile); + +export interface WeblinkConfig { + url: string; + path: string; + branch: string; +} + +const DEFAULTS: WeblinkConfig = { + url: 'https://github.com/BentoBoxWorld/weblink.git', + // resolved relative to the process cwd (which is dist/ at runtime, + // matching how config.json / thirdparty.json are read) + path: './../data/weblink', + branch: 'master', +}; + +export class WeblinkSync { + readonly config: WeblinkConfig; + private syncing = false; + + constructor(overrides: Partial = {}) { + this.config = { ...DEFAULTS, ...overrides }; + } + + get localPath(): string { + return path.resolve(this.config.path); + } + + get blueprintsDir(): string { + return path.join(this.localPath, 'blueprints'); + } + + private isRepo(): boolean { + return fs.existsSync(path.join(this.localPath, '.git')); + } + + async sync(): Promise { + if (this.syncing) return; + this.syncing = true; + try { + if (!fs.existsSync(this.localPath)) { + fs.mkdirSync(this.localPath, { recursive: true }); + } + if (this.isRepo()) { + await this.pull(); + } else { + await this.clone(); + } + } catch (err) { + console.error('[weblink] sync failed:', (err as Error).message); + } finally { + this.syncing = false; + } + } + + private async clone(): Promise { + const parent = path.dirname(this.localPath); + const target = path.basename(this.localPath); + await execFileP( + 'git', + ['clone', '--depth', '1', '--branch', this.config.branch, this.config.url, target], + { cwd: parent }, + ); + console.log('[weblink] cloned', this.config.url, '→', this.localPath); + } + + private async pull(): Promise { + await execFileP('git', ['pull', '--ff-only', '--depth', '1'], { cwd: this.localPath }); + } +} diff --git a/src/config.d.ts b/src/config.d.ts index 6259090..fe2e513 100644 --- a/src/config.d.ts +++ b/src/config.d.ts @@ -52,3 +52,67 @@ export interface tag { color: string; description: string; } + +export interface BlueprintStats { + dimensions: { x: number; y: number; z: number }; + blockCount: number; + attachedCount: number; + entityCount: number; + airCount: number; + biomes: string[]; + sinking: boolean; + topBlocks: Array<{ material: string; count: number }>; + topEntities: Array<{ type: string; count: number }>; +} + +export interface BlueprintEntry { + id: string; + gameMode: string; + name: string; + displayName: string; + description: string[]; + icon: string; + file: string; + sizeBytes: number; + stats: BlueprintStats; + author?: string; + authorLink?: string; + tags?: string[]; + license?: string; + image?: string; +} + +export interface BundleEntry { + id: string; + gameMode: string; + uniqueId: string; + displayName: string; + description: string[]; + icon: string; + file: string; + blueprints: Record; + requirePermission?: boolean; + cost?: number; + times?: number; + author?: string; + tags?: string[]; +} + +export interface BlueprintCatalogTag { + color: string; + description?: string; +} + +export interface BlueprintCatalogGameMode { + displayName?: string; + description?: string; + color?: string; +} + +export interface BlueprintCatalog { + blueprints: BlueprintEntry[]; + bundles: BundleEntry[]; + tags: Record; + gameModes: Record; + generatedAt: number; +} diff --git a/src/web/ApiRequestManager.ts b/src/web/ApiRequestManager.ts index bba2fa2..ee77c8f 100644 --- a/src/web/ApiRequestManager.ts +++ b/src/web/ApiRequestManager.ts @@ -1,4 +1,4 @@ -import { AddonType, PresetsEntity, ThirdParty } from '../config'; +import { AddonType, BlueprintCatalog, PresetsEntity, ThirdParty } from '../config'; import axios from 'axios'; export async function GetPresets(): Promise { @@ -12,3 +12,19 @@ export async function GetAddons(): Promise { export async function GetThirdParty(): Promise { return (await axios.get('/api/thirdparty')).data; } + +export async function GetBlueprints(): Promise { + return (await axios.get('/api/blueprints')).data; +} + +export function BlueprintFileUrl(id: string, type: 'blueprint' | 'bundle' = 'blueprint'): string { + return `/api/blueprints/download?id=${encodeURIComponent(id)}&type=${type}`; +} + +export function BlueprintZipUrl(ids: string[]): string { + return `/api/blueprints/zip?ids=${encodeURIComponent(JSON.stringify(ids))}`; +} + +export function BlueprintGameModeZipUrl(gameMode: string): string { + return `/api/blueprints/zip?gameMode=${encodeURIComponent(gameMode)}`; +} diff --git a/src/web/components/Blueprints.tsx b/src/web/components/Blueprints.tsx new file mode 100644 index 0000000..d5fcb96 --- /dev/null +++ b/src/web/components/Blueprints.tsx @@ -0,0 +1,317 @@ +import React, { useMemo, useState } from 'react'; +import tw from 'twin.macro'; +import { css } from 'styled-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDownload, faArrowDown } from '@fortawesome/free-solid-svg-icons'; +import { BlueprintCatalog, BlueprintCatalogGameMode, BlueprintEntry } from '../../config'; +import { + BlueprintFileUrl, + BlueprintGameModeZipUrl, + BlueprintZipUrl, +} from '../ApiRequestManager'; + +function humanSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function shortMaterial(mat: string): string { + return mat.startsWith('minecraft:') ? mat.slice('minecraft:'.length) : mat; +} + +function Placeholder({ icon }: { icon: string }) { + return ( +
+ {shortMaterial(icon).replace(/_/g, ' ')} +
+ ); +} + +function BlueprintCard({ + bp, + gameModeInfo, + selected, + onToggle, + tagColors, +}: { + bp: BlueprintEntry; + gameModeInfo?: BlueprintCatalogGameMode; + selected: boolean; + onToggle: () => void; + tagColors: Record; +}) { + const gameModeLabel = gameModeInfo?.displayName || bp.gameMode; + return ( +
+
+ + + + +
+ + {bp.image ? ( + {bp.displayName} + ) : ( + + )} + +
+

{bp.displayName}

+

+ {gameModeLabel} + {bp.author && ( + <> + {' · '} + {bp.authorLink ? ( + + {bp.author} + + ) : ( + bp.author + )} + + )} +

+
+ + {bp.description.length > 0 && ( +
+ {bp.description.map((line, i) => ( +
{line}
+ ))} +
+ )} + +
+
+ Size: {bp.stats.dimensions.x}×{bp.stats.dimensions.y}× + {bp.stats.dimensions.z} +
+
+ Blocks: {bp.stats.blockCount.toLocaleString()} +
+
+ Entities: {bp.stats.entityCount.toLocaleString()} +
+
+ Air: {bp.stats.airCount.toLocaleString()} +
+ {bp.stats.sinking && ( +
+ Sinking +
+ )} +
+ File: {humanSize(bp.sizeBytes)} +
+
+ + {bp.stats.topBlocks.length > 0 && ( +
+
Top blocks:
+
+ {bp.stats.topBlocks.map((b) => ( + + {shortMaterial(b.material)} × {b.count} + + ))} +
+
+ )} + + {bp.stats.topEntities.length > 0 && ( +
+
Entities:
+
+ {bp.stats.topEntities.map((e) => ( + + {e.type} × {e.count} + + ))} +
+
+ )} + + {bp.stats.biomes.length > 0 && ( +
+
Biomes:
+
+ {bp.stats.biomes.map((biome) => ( + + {biome} + + ))} +
+
+ )} + + {bp.tags && bp.tags.length > 0 && ( +
+ {bp.tags.sort().map((tag) => ( + + {tag} + + ))} +
+ )} + + {bp.license && ( +
License: {bp.license}
+ )} +
+ ); +} + +export default function BlueprintsPage({ data }: { data: BlueprintCatalog }) { + const [gameModeFilter, setGameModeFilter] = useState('all'); + const [selected, setSelected] = useState>(new Set()); + + const gameModes = useMemo(() => { + const s = new Set(); + data.blueprints.forEach((b) => s.add(b.gameMode)); + return [...s].sort(); + }, [data.blueprints]); + + const tagColors = useMemo(() => { + const out: Record = {}; + for (const [name, tag] of Object.entries(data.tags || {})) out[name] = tag.color; + return out; + }, [data.tags]); + + const filtered = useMemo( + () => + gameModeFilter === 'all' + ? data.blueprints + : data.blueprints.filter((b) => b.gameMode === gameModeFilter), + [data.blueprints, gameModeFilter], + ); + + function toggle(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function clearSelection() { + setSelected(new Set()); + } + + return ( + <> +
+

Island Blueprints

+

+ Drop-in .blueprint and bundle files for BentoBox game modes. +

+

+ Served from  + BentoBoxWorld/weblink. Submissions and + user accounts are coming soon. +

+
+ +
+
+ Filter By Game Mode:  + +
+ {gameModeFilter !== 'all' && ( + + +  Download all for {data.gameModes[gameModeFilter]?.displayName || gameModeFilter} + + )} +
+ + {filtered.length === 0 ? ( +
+ No blueprints found. Add .blueprint files to weblink/blueprints. +
+ ) : ( +
+ {filtered.map((bp) => ( + toggle(bp.id)} + tagColors={tagColors} + /> + ))} +
+ )} + + {selected.size > 0 && ( +
+ {selected.size} selected + + +  Download ZIP + + +
+ )} + + ); +} diff --git a/src/web/components/ContentBox.tsx b/src/web/components/ContentBox.tsx index 39cd3fe..31ee18b 100644 --- a/src/web/components/ContentBox.tsx +++ b/src/web/components/ContentBox.tsx @@ -2,7 +2,7 @@ import React, { Suspense } from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import tw from 'twin.macro'; import Navigation from './Navigation'; -import { GetAddons, GetPresets, GetThirdParty } from '../ApiRequestManager'; +import { GetAddons, GetBlueprints, GetPresets, GetThirdParty } from '../ApiRequestManager'; import { Async } from 'react-async'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; @@ -11,6 +11,7 @@ import { faQuestion } from '@fortawesome/free-solid-svg-icons'; const PresetsPage = React.lazy(() => import('./PresetsPage')); const CustomPage = React.lazy(() => import('./CustomPage')); const ThirdPartyPage = React.lazy(() => import('./ThirdParty')); +const BlueprintsPage = React.lazy(() => import('./Blueprints')); export default function ContentBox() { function CustomElement() { @@ -55,6 +56,20 @@ export default function ContentBox() { ); } + function BlueprintsElement() { + const data = () => GetBlueprints(); + return ( + }> + + {({ data, isLoading }) => { + if (isLoading) return <>; + if (data) return ; + }} + + + ); + } + return (
@@ -66,6 +81,9 @@ export default function ContentBox() { + + + diff --git a/src/web/components/Navigation.tsx b/src/web/components/Navigation.tsx index afa76cc..80f58b1 100644 --- a/src/web/components/Navigation.tsx +++ b/src/web/components/Navigation.tsx @@ -2,7 +2,7 @@ import { useLocation } from 'react-router'; import tw from 'twin.macro'; import { NavLink } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faImage, faPuzzlePiece, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { faImage, faLayerGroup, faPuzzlePiece, faWrench } from '@fortawesome/free-solid-svg-icons'; import React, { useEffect } from 'react'; import { faDiscord } from '@fortawesome/free-brands-svg-icons'; @@ -56,6 +56,20 @@ export default function Navigation() {
+
  • +
    + + +  Blueprints + +
    +
  • Date: Fri, 24 Apr 2026 20:39:20 -0700 Subject: [PATCH 02/20] docs: document Blueprints tab + weblink sync in CLAUDE.md Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9501c2..42e19b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,15 +30,22 @@ yarn site # Frontend-only dev rebuild + start server **Frontend** (`src/web/`): - React 17 SPA with React Router, styled with Tailwind CSS + twin.macro/styled-components -- Three pages: Presets (`/`), Custom builder (`/custom`), Third-party catalog (`/thirdparty`) +- Four pages: Presets (`/`), Custom builder (`/custom`), Third-party catalog (`/thirdparty`), Blueprints (`/blueprints`) - Components in `src/web/components/`, API calls via `src/web/ApiRequestManager.ts` using SWR **Configuration files** (project root, read at runtime from `../` relative to `dist/`): - `config.json` — addon and preset definitions (the primary data source for all addons) - `thirdparty.json` — third-party addon registry -- `env.json` (optional, from `env.example.json`) — GitHub token, Discord webhook, port settings +- `env.json` (optional, from `env.example.json`) — GitHub token, Discord webhook, port settings, optional `weblink` block (url/path/branch) for the blueprint sync - `Installation-Guide.txt` — bundled in generated ZIP files +**Blueprint catalog** (`src/api/weblink.ts`, `src/api/blueprintCatalog.ts`): +- `WeblinkSync` shallow-clones `BentoBoxWorld/weblink` into `data/weblink/` on startup and `git pull --ff-only`s it on the same 6-minute cron used for addon refresh. Default branch is `master`. +- `buildCatalog()` scans `weblink/blueprints//`, parses `.blueprint` files and bundle `.json` files (per BentoBox's `blueprint.schema.json` / `blueprint-bundle.schema.json`), and derives stats: dimensions, block-type counts, entity-type counts, air count, biome list, sinking flag. +- The optional `blueprints/catalog.json` overlay in weblink supplies curation fields (`displayName`, `description`, `author`, `authorLink`, `tags`, `license`, `image`) plus tag colours and game-mode display names; overlay fields take precedence over what is in the `.blueprint` file. +- Endpoints: `GET /api/blueprints` (full catalog), `GET /api/blueprints/download?id=/&type=blueprint|bundle` (single file), `GET /api/blueprints/zip?ids=[...]|gameMode=` (multi-select or whole-game-mode zip). IDs are validated against a strict regex and resolved paths are checked against the blueprints root before any read. +- Phase-2 submission sanitisers live next to the catalog code: `sanitizeBundleSubmission` strips `BlueprintBundle.commands` (console-command injection vector) and `validateBlueprintSubmission` rejects command-block materials and any block carrying an opaque `inventory` payload. They are not called by Phase-1 read-only flows; wire them in when the upload endpoint lands. + ## Key Patterns - All addon metadata is configuration-driven via `config.json`; adding an addon means editing that file @@ -50,3 +57,92 @@ yarn site # Frontend-only dev rebuild + start server ## CI GitHub Actions (`.github/workflows/build.yml`) runs on push/PR to `develop`: tests Node.js 12.x/14.x/16.x with `yarn && yarn add sqlite3 && yarn build`. + +## Dependency Source Lookup + +When you need to inspect source code for a dependency (e.g., BentoBox, addons): + +1. **Check local Maven repo first**: `~/.m2/repository/` — sources jars are named `*-sources.jar` +2. **Check the workspace**: Look for sibling directories or Git submodules that may contain the dependency as a local project (e.g., `../bentoBox`, `../addon-*`) +3. **Check Maven local cache for already-extracted sources** before downloading anything +4. Only download a jar or fetch from the internet if the above steps yield nothing useful + +Prefer reading `.java` source files directly from a local Git clone over decompiling or extracting a jar. + +In general, the latest version of BentoBox should be targeted. + +## Project Layout + +Related projects are checked out as siblings under `~/git/`: + +**Core:** +- `bentobox/` — core BentoBox framework + +**Game modes:** +- `addon-acidisland/` — AcidIsland game mode +- `addon-bskyblock/` — BSkyBlock game mode +- `Boxed/` — Boxed game mode (expandable box area) +- `CaveBlock/` — CaveBlock game mode +- `OneBlock/` — AOneBlock game mode +- `SkyGrid/` — SkyGrid game mode +- `RaftMode/` — Raft survival game mode +- `StrangerRealms/` — StrangerRealms game mode +- `Brix/` — plot game mode +- `parkour/` — Parkour game mode +- `poseidon/` — Poseidon game mode +- `gg/` — gg game mode + +**Addons:** +- `addon-level/` — island level calculation +- `addon-challenges/` — challenges system +- `addon-welcomewarpsigns/` — warp signs +- `addon-limits/` — block/entity limits +- `addon-invSwitcher/` / `invSwitcher/` — inventory switcher +- `addon-biomes/` / `Biomes/` — biomes management +- `Bank/` — island bank +- `Border/` — world border for islands +- `Chat/` — island chat +- `CheckMeOut/` — island submission/voting +- `ControlPanel/` — game mode control panel +- `Converter/` — ASkyBlock to BSkyBlock converter +- `DimensionalTrees/` — dimension-specific trees +- `discordwebhook/` — Discord integration +- `Downloads/` — BentoBox downloads site +- `DragonFights/` — per-island ender dragon fights +- `ExtraMobs/` — additional mob spawning rules +- `FarmersDance/` — twerking crop growth +- `GravityFlux/` — gravity addon +- `Greenhouses-addon/` — greenhouse biomes +- `IslandFly/` — island flight permission +- `IslandRankup/` — island rankup system +- `Likes/` — island likes/dislikes +- `Limits/` — block/entity limits +- `lost-sheep/` — lost sheep adventure +- `MagicCobblestoneGenerator/` — custom cobblestone generator +- `PortalStart/` — portal-based island start +- `pp/` — pp addon +- `Regionerator/` — region management +- `Residence/` — residence addon +- `TopBlock/` — top ten for OneBlock +- `TwerkingForTrees/` — twerking tree growth +- `Upgrades/` — island upgrades (Vault) +- `Visit/` — island visiting +- `weblink/` — data repo (blueprints, challenges, MCG configs, addon downloads matrix); the Downloads server clones a copy of this for the Blueprints tab +- `CrowdBound/` — CrowdBound addon + +**Data packs:** +- `BoxedDataPack/` — advancement datapack for Boxed + +**Documentation & tools:** +- `docs/` — main documentation site +- `docs-chinese/` — Chinese documentation +- `docs-french/` — French documentation +- `BentoBoxWorld.github.io/` — GitHub Pages site +- `website/` — website +- `translation-tool/` — translation tool + +Check these for source before any network fetch. + +## Key Dependencies (source locations) + +- `world.bentobox:bentobox` → `~/git/bentobox/src/` From 8f2ec2832a8ec593e65dd0be9c5146f21f899731 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 24 Apr 2026 21:07:41 -0700 Subject: [PATCH 03/20] Add Discord-authenticated blueprint submissions + image serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — Images: weblink/blueprints/images//.png is the canonical location. blueprintCatalog.buildCatalog() auto-detects images (and an optional .thumb.png variant) when the overlay doesn't specify one. A new /blueprints/images/* handler in src/index.ts serves the cloned tree with path-traversal guard and PNG-only whitelist. Phase B — Discord OAuth: Server-side authorization-code flow in src/api/auth.ts (identify scope only). HttpOnly + signed session cookie via cookie-parser, with SameSite=Lax, Secure-in-prod, 30-day TTL, 5 concurrent sessions per user, and a daily prune cron. New SQLite at data/Auth.sqlite hosts User, Session, and Submission tables (src/api/models/auth.ts). Helmet config rebuilt with explicit CSP that allows Discord avatar CDN images while leaving top-level OAuth navigation unconstrained. Phase C — Submissions: POST /api/blueprints/submit accepts a multipart upload (multer, 5 MB cap), JSON-parses, ajv-validates against the vendored blueprint.schema.json, runs validateBlueprintSubmission (rejects command-block materials + opaque inventory payloads), checks for a slug collision against the local weblink clone, then opens a PR against BentoBoxWorld/weblink via Octokit using env.weblink_github_token. The catalog.json patch credits the user's Discord profile and records the EPL-2.0 license. New /submit React page (login gate + form + ToS checkbox). Phase D — Hardening: Per-session CSRF token returned by /api/me and required as X-Csrf-Token on every POST. Sliding-window in-memory rate limiters: 30 OAuth callbacks/min/IP, 3 submissions/hour and 10/day per user. Discord access token never logged. Phase E — Legal + account: /terms, /privacy and /account pages. Account page lists submissions, allows logout, and a confirm-then-delete CCPA-compliant account deletion path. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 12 +- env.example.json | 6 +- package.json | 10 +- src/api/api.ts | 46 ++ src/api/auth.ts | 340 +++++++++ src/api/blueprintCatalog.ts | 17 +- src/api/models/auth.ts | 103 +++ src/api/schemas/blueprint.schema.json | 579 ++++++++++++++++ src/api/submissions.ts | 364 ++++++++++ src/index.ts | 101 ++- src/web/ApiRequestManager.ts | 71 ++ src/web/components/Account.tsx | 162 +++++ src/web/components/Blueprints.tsx | 8 +- src/web/components/ContentBox.tsx | 24 + src/web/components/Privacy.tsx | 79 +++ src/web/components/Submit.tsx | 295 ++++++++ src/web/components/Terms.tsx | 87 +++ yarn.lock | 952 +++++++++----------------- 18 files changed, 2630 insertions(+), 626 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/api/models/auth.ts create mode 100644 src/api/schemas/blueprint.schema.json create mode 100644 src/api/submissions.ts create mode 100644 src/web/components/Account.tsx create mode 100644 src/web/components/Privacy.tsx create mode 100644 src/web/components/Submit.tsx create mode 100644 src/web/components/Terms.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 42e19b3..6bf499d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ yarn site # Frontend-only dev rebuild + start server **Frontend** (`src/web/`): - React 17 SPA with React Router, styled with Tailwind CSS + twin.macro/styled-components -- Four pages: Presets (`/`), Custom builder (`/custom`), Third-party catalog (`/thirdparty`), Blueprints (`/blueprints`) +- Pages: Presets (`/`), Custom builder (`/custom`), Third-party catalog (`/thirdparty`), Blueprints (`/blueprints`), Submit (`/submit`), Account (`/account`), Terms (`/terms`), Privacy (`/privacy`) - Components in `src/web/components/`, API calls via `src/web/ApiRequestManager.ts` using SWR **Configuration files** (project root, read at runtime from `../` relative to `dist/`): @@ -43,8 +43,16 @@ yarn site # Frontend-only dev rebuild + start server - `WeblinkSync` shallow-clones `BentoBoxWorld/weblink` into `data/weblink/` on startup and `git pull --ff-only`s it on the same 6-minute cron used for addon refresh. Default branch is `master`. - `buildCatalog()` scans `weblink/blueprints//`, parses `.blueprint` files and bundle `.json` files (per BentoBox's `blueprint.schema.json` / `blueprint-bundle.schema.json`), and derives stats: dimensions, block-type counts, entity-type counts, air count, biome list, sinking flag. - The optional `blueprints/catalog.json` overlay in weblink supplies curation fields (`displayName`, `description`, `author`, `authorLink`, `tags`, `license`, `image`) plus tag colours and game-mode display names; overlay fields take precedence over what is in the `.blueprint` file. +- Images live at `weblink/blueprints/images//.png` (recommended 800×450, with optional `.thumb.png` at 400×225). The catalog auto-detects them at sync time when the overlay does not specify `image`. The Express handler at `src/index.ts` serves them under `/blueprints/images/...` with a path-traversal guard and PNG-only whitelist. - Endpoints: `GET /api/blueprints` (full catalog), `GET /api/blueprints/download?id=/&type=blueprint|bundle` (single file), `GET /api/blueprints/zip?ids=[...]|gameMode=` (multi-select or whole-game-mode zip). IDs are validated against a strict regex and resolved paths are checked against the blueprints root before any read. -- Phase-2 submission sanitisers live next to the catalog code: `sanitizeBundleSubmission` strips `BlueprintBundle.commands` (console-command injection vector) and `validateBlueprintSubmission` rejects command-block materials and any block carrying an opaque `inventory` payload. They are not called by Phase-1 read-only flows; wire them in when the upload endpoint lands. + +**Auth + submissions** (`src/api/auth.ts`, `src/api/submissions.ts`, `src/api/models/auth.ts`): +- Discord OAuth (server-side authorization-code flow, `identify` scope only). Sessions stored in `data/Auth.sqlite` via Sequelize (`User`, `Session`, `Submission` tables). Session cookie is HttpOnly, signed via `cookie-parser`, `SameSite=Lax`, `Secure` in production. 30-day TTL, max 5 concurrent sessions per user, daily prune cron. +- CSRF: per-session token returned by `GET /api/me`, required as `X-Csrf-Token` on every POST. +- Rate limits: 30 OAuth callbacks/min/IP; 3 submissions/hour, 10/day per Discord user. +- `POST /api/blueprints/submit` accepts a multipart upload (max 5 MB), JSON-parses, ajv-validates against `src/api/schemas/blueprint.schema.json` (vendored from `bentobox/`), runs `validateBlueprintSubmission` (rejects command-block materials and opaque `inventory` payloads), checks slug collision against the local clone, then opens a PR on `BentoBoxWorld/weblink` via Octokit using `env.weblink_github_token` — branch `submit//-` with two file commits (the `.blueprint` and the patched `catalog.json`). +- Helmet config in `src/index.ts` is the canonical CSP — Discord OAuth navigation works without an explicit allowlist because helmet's CSP does not constrain top-level navigation. +- `env.json` keys: `discord_client_id`, `discord_client_secret`, `discord_redirect_uri`, `weblink_github_token` (fine-grained PAT scoped to `BentoBoxWorld/weblink` with `contents: write` + `pull-requests: write`). ## Key Patterns diff --git a/env.example.json b/env.example.json index 72ad8d3..b788218 100644 --- a/env.example.json +++ b/env.example.json @@ -7,5 +7,9 @@ "url": "https://github.com/BentoBoxWorld/weblink.git", "path": "./../data/weblink", "branch": "master" - } + }, + "discord_client_id": "0000000000000000000", + "discord_client_secret": "REPLACE_ME", + "discord_redirect_uri": "https://download.bentobox.world/api/auth/discord/callback", + "weblink_github_token": "github_pat_REPLACE_ME" } diff --git a/package.json b/package.json index 2618f28..7a58a74 100644 --- a/package.json +++ b/package.json @@ -25,20 +25,25 @@ "@octokit/plugin-throttling": "^3.5.1", "@tailwindcss/custom-forms": "^0.2.1", "@types/archiver": "^5.1.1", - "@types/express": "^4.17.13", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^4.17.21", "@types/jenkins": "^0.23.2", "@types/mime-types": "^2.1.0", "@types/node-cron": "^2.0.4", "@types/react-router-dom": "^5.1.8", "@types/styled-components": "^5.1.11", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "archiver": "^5.3.0", "axios": "^0.21.1", "clean-webpack-plugin": "^4.0.0-alpha.0", + "cookie-parser": "^1.4.7", "copy-webpack-plugin": "^9.0.1", "express": "^4.17.1", "helmet": "^4.6.0", "jenkins": "^0.28.1", "mime-types": "^2.1.31", + "multer": "^2.1.1", "node-cron": "^3.0.0", "octokit": "^1.1.0", "react": "^17.0.2", @@ -50,7 +55,7 @@ "remark-breaks": "^2.0.2", "sequelize": "^6.6.5", "source-map-loader": "^3.0.0", - "sqlite3": "^5.0.2", + "sqlite3": "^6.0.1", "styled-components": "^5.3.0", "swr": "^0.5.6", "tailwind": "^4.0.0", @@ -64,6 +69,7 @@ "@babel/preset-react": "^7.13.13", "@babel/preset-typescript": "^7.13.0", "@babel/runtime": "^7.13.16", + "@types/multer": "^2.1.0", "@types/react": "^17.0.15", "@types/react-dom": "^17.0.3", "@typescript-eslint/eslint-plugin": "^4.28.5", diff --git a/src/api/api.ts b/src/api/api.ts index 4102734..c0d89f0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -22,9 +22,16 @@ import { parseBlueprintId, resolveBlueprintFile, } from './blueprintCatalog'; +import { AuthConfig, AuthManager } from './auth'; +import { SubmissionManager } from './submissions'; const { throttling } = require('@octokit/plugin-throttling'); +// Ensure the data/ directory exists for Auth.sqlite + weblink clone. +try { + fs.mkdirSync('./../data', { recursive: true }); +} catch (e) {} + export default class ApiManager { config: ConfigObject; addons: AddonType[] = []; @@ -42,6 +49,15 @@ export default class ApiManager { generatedAt: 0, }; + authSequelize = new Sequelize('auth', 'user', 'password', { + host: 'localhost', + dialect: 'sqlite', + logging: false, + storage: './../data/Auth.sqlite', + }); + auth: AuthManager = new AuthManager(this.loadAuthConfig(), this.authSequelize); + submissions: SubmissionManager = new SubmissionManager(this.auth, this.weblink, this.loadSubmissionsConfig()); + jarSequelize = new Sequelize('database', 'user', 'password', { host: 'localhost', dialect: 'sqlite', @@ -188,6 +204,36 @@ export default class ApiManager { return new WeblinkSync(overrides); } + private loadSubmissionsConfig() { + try { + const env = require('./../../env.json'); + if (env && env.weblink_github_token) { + return { + weblinkRepo: { owner: 'BentoBoxWorld', repo: 'weblink' }, + weblinkToken: env.weblink_github_token as string, + weblinkBranch: (env.weblink && env.weblink.branch) || 'master', + }; + } + } catch (e) {} + return null; + } + + private loadAuthConfig(): AuthConfig | null { + try { + const env = require('./../../env.json'); + const inProd = process.env.NODE_ENV === 'production'; + if (env && env.discord_client_id && env.discord_client_secret && env.discord_redirect_uri) { + return { + clientId: env.discord_client_id, + clientSecret: env.discord_client_secret, + redirectUri: env.discord_redirect_uri, + cookieSecure: inProd, + }; + } + } catch (e) {} + return null; + } + async refreshBlueprints() { try { await this.weblink.sync(); diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..57389be --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,340 @@ +import { Request, RequestHandler, Response } from 'express'; +import * as crypto from 'crypto'; +import * as cron from 'node-cron'; +import { Op, Sequelize } from 'sequelize'; +import axios from 'axios'; +import { + SessionFactory, + SessionModel, + SessionStatic, + SubmissionFactory, + SubmissionStatic, + UserFactory, + UserStatic, +} from './models/auth'; + +export interface AuthConfig { + clientId: string; + clientSecret: string; + redirectUri: string; + cookieSecure: boolean; // true in prod, false on localhost dev +} + +const SESSION_COOKIE = 'bb_session'; +const STATE_COOKIE = 'bb_oauth_state'; +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const STATE_TTL_MS = 10 * 60 * 1000; +const MAX_SESSIONS_PER_USER = 5; + +export const TERMS_VERSION = '2026-04-24'; + +export interface AuthedRequest extends Request { + session?: SessionModel; + user?: { id: string; username: string }; +} + +export class AuthManager { + private readonly users: UserStatic; + private readonly sessions: SessionStatic; + readonly submissions: SubmissionStatic; + + constructor(private readonly config: AuthConfig | null, sequelize: Sequelize) { + this.users = UserFactory(sequelize); + this.sessions = SessionFactory(sequelize); + this.submissions = SubmissionFactory(sequelize); + this.users.sync(); + this.sessions.sync(); + this.submissions.sync(); + + // Daily prune of expired sessions. + cron.schedule('0 4 * * *', () => this.pruneExpiredSessions()); + } + + isConfigured(): boolean { + return !!this.config && !!this.config.clientId && !!this.config.clientSecret; + } + + // -------- OAuth flow -------- + + handleLogin: RequestHandler = (_req, res) => { + if (!this.isConfigured() || !this.config) { + res.status(503).send('Discord login is not configured on this server.'); + return; + } + const state = randomToken(); + res.cookie(STATE_COOKIE, state, { + httpOnly: true, + secure: this.config.cookieSecure, + sameSite: 'lax', + maxAge: STATE_TTL_MS, + path: '/', + }); + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: 'code', + scope: 'identify', + redirect_uri: this.config.redirectUri, + state, + prompt: 'none', + }); + res.redirect(`https://discord.com/api/oauth2/authorize?${params.toString()}`); + }; + + handleCallback: RequestHandler = async (req, res) => { + if (!this.isConfigured() || !this.config) { + res.status(503).send('Discord login is not configured on this server.'); + return; + } + const code = typeof req.query.code === 'string' ? req.query.code : ''; + const state = typeof req.query.state === 'string' ? req.query.state : ''; + const cookieState = (req as Request & { cookies?: Record }).cookies?.[STATE_COOKIE]; + if (!code || !state || !cookieState || !timingSafeEqualStr(state, cookieState)) { + res.status(400).send('OAuth state mismatch. Please try again.'); + return; + } + res.clearCookie(STATE_COOKIE, { path: '/' }); + + let identity: { id: string; username: string; global_name: string | null; avatar: string | null }; + try { + const tokenResp = await axios.post( + 'https://discord.com/api/oauth2/token', + new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: this.config.redirectUri, + }).toString(), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, + ); + const accessToken = tokenResp.data.access_token as string; + // Fetch identity, then forget the token. We never persist it. + const me = await axios.get('https://discord.com/api/users/@me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + identity = me.data; + } catch (err) { + // Deliberately do not log err.config or err.response.data — token may be in there. + console.error('[auth] Discord token exchange failed'); + res.status(502).send('Could not complete Discord login. Please try again.'); + return; + } + + const now = Date.now(); + const existing = await this.users.findByPk(identity.id); + if (existing) { + await existing.update({ + username: identity.username, + globalName: identity.global_name, + avatarHash: identity.avatar, + lastLoginAt: now, + }); + } else { + await this.users.create({ + id: identity.id, + username: identity.username, + globalName: identity.global_name, + avatarHash: identity.avatar, + createdAt: now, + lastLoginAt: now, + acceptedTermsVersion: null, + }); + } + + // Cap concurrent sessions per user — evict oldest. + const userSessions = await this.sessions.findAll({ + where: { userId: identity.id }, + order: [['createdAt', 'ASC']], + }); + const overflow = userSessions.length - (MAX_SESSIONS_PER_USER - 1); + for (let i = 0; i < overflow; i++) { + await userSessions[i].destroy(); + } + + const sessionId = randomToken(); + const csrfToken = randomToken(); + await this.sessions.create({ + sessionId, + userId: identity.id, + csrfToken, + createdAt: now, + lastSeenAt: now, + expiresAt: now + SESSION_TTL_MS, + }); + res.cookie(SESSION_COOKIE, sessionId, { + httpOnly: true, + secure: this.config.cookieSecure, + sameSite: 'lax', + maxAge: SESSION_TTL_MS, + path: '/', + }); + res.redirect('/submit'); + }; + + handleLogout: RequestHandler = async (req, res) => { + const session = (req as AuthedRequest).session; + if (session) { + await session.destroy(); + } + res.clearCookie(SESSION_COOKIE, { path: '/' }); + res.json({ ok: true }); + }; + + handleMe: RequestHandler = async (req, res) => { + const session = (req as AuthedRequest).session; + if (!session) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const user = await this.users.findByPk(session.userId); + if (!user) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + res.json({ + id: user.id, + username: user.username, + globalName: user.globalName, + avatarUrl: user.avatarHash + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatarHash}.png?size=128` + : null, + acceptedTermsVersion: user.acceptedTermsVersion, + csrfToken: session.csrfToken, + currentTermsVersion: TERMS_VERSION, + }); + }; + + handleDeleteMe: RequestHandler = async (req, res) => { + const session = (req as AuthedRequest).session; + if (!session) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const userId = session.userId; + await this.sessions.destroy({ where: { userId } }); + await this.users.destroy({ where: { id: userId } }); + res.clearCookie(SESSION_COOKIE, { path: '/' }); + res.json({ ok: true }); + }; + + // -------- Session middleware -------- + + loadSession: RequestHandler = async (req, _res, next) => { + const cookies = (req as Request & { cookies?: Record }).cookies; + const sid = cookies?.[SESSION_COOKIE]; + if (!sid) return next(); + const session = await this.sessions.findByPk(sid); + if (!session) return next(); + if (session.expiresAt < Date.now()) { + await session.destroy(); + return next(); + } + if (Date.now() - session.lastSeenAt > 60_000) { + session.lastSeenAt = Date.now(); + await session.save(); + } + (req as AuthedRequest).session = session; + (req as AuthedRequest).user = { id: session.userId, username: '' }; + next(); + }; + + requireSession: RequestHandler = (req, res, next) => { + if (!(req as AuthedRequest).session) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + next(); + }; + + requireCsrf: RequestHandler = (req, res, next) => { + const session = (req as AuthedRequest).session; + if (!session) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const provided = req.header('x-csrf-token'); + if (!provided || !timingSafeEqualStr(provided, session.csrfToken)) { + res.status(403).json({ error: 'csrf' }); + return; + } + next(); + }; + + // -------- Rate limiting -------- + // + // Sliding-window in-memory limiter keyed by IP for the OAuth callback, + // and by Discord user-id for submissions. Map entries are pruned lazily + // on read, so memory stays bounded by active actors. + + private callbackHits = new Map(); + private static CALLBACK_WINDOW_MS = 60_000; + private static CALLBACK_LIMIT = 30; + + callbackRateLimit: RequestHandler = (req, res, next) => { + const ip = (req.ip || req.socket.remoteAddress || 'unknown').toString(); + const now = Date.now(); + const window = this.callbackHits.get(ip)?.filter((t) => now - t < AuthManager.CALLBACK_WINDOW_MS) ?? []; + if (window.length >= AuthManager.CALLBACK_LIMIT) { + res.status(429).send('Too many auth callbacks; slow down.'); + return; + } + window.push(now); + this.callbackHits.set(ip, window); + next(); + }; + + private submissionHits = new Map(); + private static SUBMISSION_HOUR_MS = 60 * 60 * 1000; + private static SUBMISSION_DAY_MS = 24 * 60 * 60 * 1000; + private static SUBMISSION_PER_HOUR = 3; + private static SUBMISSION_PER_DAY = 10; + + consumeSubmissionQuota(userId: string): { ok: true } | { ok: false; reason: string } { + const now = Date.now(); + const hits = (this.submissionHits.get(userId) ?? []).filter( + (t) => now - t < AuthManager.SUBMISSION_DAY_MS, + ); + const inHour = hits.filter((t) => now - t < AuthManager.SUBMISSION_HOUR_MS).length; + if (inHour >= AuthManager.SUBMISSION_PER_HOUR) { + return { ok: false, reason: 'hourly submission limit reached' }; + } + if (hits.length >= AuthManager.SUBMISSION_PER_DAY) { + return { ok: false, reason: 'daily submission limit reached' }; + } + hits.push(now); + this.submissionHits.set(userId, hits); + return { ok: true }; + } + + // -------- Helpers used by submissions -------- + + async recordTermsAccepted(userId: string, version: string): Promise { + await this.users.update({ acceptedTermsVersion: version }, { where: { id: userId } }); + } + + async findUser(userId: string) { + return this.users.findByPk(userId); + } + + private async pruneExpiredSessions(): Promise { + try { + const removed = await this.sessions.destroy({ + where: { expiresAt: { [Op.lt]: Date.now() } }, + }); + if (removed > 0) console.log(`[auth] pruned ${removed} expired sessions`); + } catch (err) { + console.error('[auth] session prune failed:', (err as Error).message); + } + } +} + +// -------- Module helpers -------- + +function randomToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +function timingSafeEqualStr(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} diff --git a/src/api/blueprintCatalog.ts b/src/api/blueprintCatalog.ts index fac346b..1dfd4fb 100644 --- a/src/api/blueprintCatalog.ts +++ b/src/api/blueprintCatalog.ts @@ -229,10 +229,23 @@ function listGameModeDirs(blueprintsDir: string): string[] { if (!fs.existsSync(blueprintsDir)) return []; return fs .readdirSync(blueprintsDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) + .filter((d) => d.isDirectory() && d.name !== 'images') .map((d) => d.name); } +// Path convention: images live at blueprints/images//.png. +// If a sibling .thumb.png exists, prefer it for catalog cards; +// otherwise use the full image. Returns the path relative to +// blueprints/images/, or undefined if no image exists for this blueprint. +function detectImage(blueprintsDir: string, gameMode: string, name: string): string | undefined { + const imagesRoot = path.join(blueprintsDir, 'images', gameMode); + const thumb = path.join(imagesRoot, `${name}.thumb.png`); + if (fs.existsSync(thumb)) return path.posix.join(gameMode, `${name}.thumb.png`); + const full = path.join(imagesRoot, `${name}.png`); + if (fs.existsSync(full)) return path.posix.join(gameMode, `${name}.png`); + return undefined; +} + export function buildCatalog(blueprintsDir: string): BlueprintCatalog { const overlay = readOverlay(blueprintsDir); const blueprints: BlueprintEntry[] = []; @@ -266,7 +279,7 @@ export function buildCatalog(blueprintsDir: string): BlueprintCatalog { authorLink: o.authorLink, tags: o.tags, license: o.license, - image: o.image, + image: o.image ?? detectImage(blueprintsDir, gameMode, name), }); } catch (err) { console.error(`[blueprints] failed to parse ${abs}:`, (err as Error).message); diff --git a/src/api/models/auth.ts b/src/api/models/auth.ts new file mode 100644 index 0000000..163c8a1 --- /dev/null +++ b/src/api/models/auth.ts @@ -0,0 +1,103 @@ +import { BuildOptions, Model, Sequelize, STRING, INTEGER } from 'sequelize'; + +// ----- User ----- + +export interface UserAttributes { + id: string; // Discord user id + username: string; + globalName: string | null; + avatarHash: string | null; + createdAt: number; // ms epoch + lastLoginAt: number; // ms epoch + acceptedTermsVersion: string | null; +} + +export interface UserModel extends Model, UserAttributes {} + +export type UserStatic = typeof Model & { + new (values?: Record, options?: BuildOptions): UserModel; +}; + +export function UserFactory(sequelize: Sequelize): UserStatic { + return sequelize.define( + 'users', + { + id: { type: STRING, primaryKey: true }, + username: STRING, + globalName: STRING, + avatarHash: STRING, + createdAt: INTEGER, + lastLoginAt: INTEGER, + acceptedTermsVersion: STRING, + }, + { timestamps: false }, + ); +} + +// ----- Session ----- + +export interface SessionAttributes { + sessionId: string; // 256-bit hex + userId: string; // -> Users.id + csrfToken: string; + createdAt: number; + lastSeenAt: number; + expiresAt: number; +} + +export interface SessionModel extends Model, SessionAttributes {} + +export type SessionStatic = typeof Model & { + new (values?: Record, options?: BuildOptions): SessionModel; +}; + +export function SessionFactory(sequelize: Sequelize): SessionStatic { + return sequelize.define( + 'sessions', + { + sessionId: { type: STRING, primaryKey: true }, + userId: STRING, + csrfToken: STRING, + createdAt: INTEGER, + lastSeenAt: INTEGER, + expiresAt: INTEGER, + }, + { timestamps: false, indexes: [{ fields: ['userId'] }] }, + ); +} + +// ----- Submission record (created when a PR is opened) ----- + +export interface SubmissionAttributes { + id: number; // autoincrement + userId: string; + gameMode: string; + name: string; + displayName: string; + prUrl: string; + termsVersion: string; + createdAt: number; +} + +export interface SubmissionModel extends Model, SubmissionAttributes {} + +export type SubmissionStatic = typeof Model & { + new (values?: Record, options?: BuildOptions): SubmissionModel; +}; + +export function SubmissionFactory(sequelize: Sequelize): SubmissionStatic { + return sequelize.define( + 'submissions', + { + id: { type: INTEGER, primaryKey: true, autoIncrement: true }, + userId: STRING, + gameMode: STRING, + name: STRING, + displayName: STRING, + prUrl: STRING, + termsVersion: STRING, + createdAt: INTEGER, + }, + { timestamps: false, indexes: [{ fields: ['userId'] }] }, + ); +} diff --git a/src/api/schemas/blueprint.schema.json b/src/api/schemas/blueprint.schema.json new file mode 100644 index 0000000..c2dd763 --- /dev/null +++ b/src/api/schemas/blueprint.schema.json @@ -0,0 +1,579 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bentobox.world/schemas/blueprint.schema.json", + "title": "BentoBox Blueprint Format", + "description": "Schema describing the on-disk JSON format used by BentoBox for island blueprints (.blueprint files) and blueprint bundles (bundle .json files).\n\nFILE FORMATS:\n - `.blueprint` files are plain UTF-8 JSON and MUST validate against `#/$defs/Blueprint`.\n - `.blu` files are a legacy format: a ZIP archive containing a single entry whose content is plain JSON that validates against `#/$defs/Blueprint`.\n - Bundle files (e.g. `default.json` inside a game mode's `blueprints/` folder) MUST validate against `#/$defs/BlueprintBundle`.\n\nSERIALIZATION NOTES (Gson-based):\n - Only fields explicitly tagged `@Expose` in the Java source are serialized; producers must not emit other keys.\n - `org.bukkit.util.Vector` values are emitted as a 3-element JSON array of numbers: `[x, y, z]`.\n - Maps keyed by `Vector` use Gson's complex-map-key form: a JSON array of 2-element arrays, i.e. `[[, ], ...]` — NOT a JSON object. This applies to `Blueprint.blocks`, `Blueprint.attached`, and `Blueprint.entities`.\n - Maps keyed by an enum (e.g. `BlueprintBundle.blueprints` keyed by `World.Environment`) ARE emitted as normal JSON objects with the enum NAME as the key.\n - Maps keyed by `Integer` (inventory slot -> ItemStack) are emitted as JSON objects with stringified integer keys.\n - `org.bukkit.inventory.ItemStack` values are serialized to Bukkit YAML (via ConfigurationSerializable) and stored as a JSON string. They cannot be validated structurally by this schema; treat the value as an opaque YAML document.\n - Enum values are serialized by their Java `name()`.\n - `org.bukkit.Color` is serialized as `{\"ALPHA\": int, \"RED\": int, \"GREEN\": int, \"BLUE\": int}` via ConfigurationSerializable.\n - Pretty-printing is enabled by the writer; consumers must not rely on whitespace.", + "type": "object", + "oneOf": [ + { "$ref": "#/$defs/Blueprint" }, + { "$ref": "#/$defs/BlueprintBundle" } + ], + "$defs": { + "Vector": { + "title": "Vector", + "description": "A Bukkit Vector serialized as a 3-element JSON array of doubles: [x, y, z]. For block positions, integer values are customary but the underlying type is double. For entity positions, sub-block fractional components are allowed.", + "type": "array", + "prefixItems": [ + { "type": "number", "description": "x" }, + { "type": "number", "description": "y" }, + { "type": "number", "description": "z" } + ], + "minItems": 3, + "maxItems": 3 + }, + + "ItemStackYaml": { + "title": "ItemStack (YAML-encoded)", + "description": "A Bukkit ItemStack serialized via Bukkit's ConfigurationSerializable as a YAML document and stored as a JSON string. The string SHOULD be parseable by `org.bukkit.inventory.ItemStack#deserialize` or by `YamlConfiguration#loadFromString`. No structural validation is performed by this schema.", + "type": "string" + }, + + "Color": { + "title": "Color", + "description": "An RGBA color, serialized via Bukkit's ConfigurationSerializable.", + "type": "object", + "properties": { + "ALPHA": { "type": "integer", "minimum": 0, "maximum": 255 }, + "RED": { "type": "integer", "minimum": 0, "maximum": 255 }, + "GREEN": { "type": "integer", "minimum": 0, "maximum": 255 }, + "BLUE": { "type": "integer", "minimum": 0, "maximum": 255 } + }, + "required": ["RED", "GREEN", "BLUE"] + }, + + "NamespacedKey": { + "title": "NamespacedKey", + "description": "A Bukkit NamespacedKey such as `minecraft:chests/simple_dungeon`.", + "type": "string", + "pattern": "^[a-z0-9._-]+:[a-z0-9/._-]+$" + }, + + "Icon": { + "title": "Icon", + "description": "Icon material. One of: a plain Bukkit Material enum name (e.g. `DIAMOND`), a vanilla namespaced key (e.g. `minecraft:diamond`), or a resource pack custom model key (e.g. `myserver:island_tropical`). Default: `PAPER`.", + "type": "string", + "minLength": 1 + }, + + "EnvironmentKey": { + "description": "A Bukkit World.Environment name.", + "type": "string", + "enum": ["NORMAL", "NETHER", "THE_END", "CUSTOM"] + }, + + "VectorKeyedBlockMap": { + "title": "Vector-keyed block map", + "description": "Gson complex-map-key form: an array of [VectorKey, BlueprintBlock] pairs.", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/Vector" }, + { "$ref": "#/$defs/BlueprintBlock" } + ], + "minItems": 2, + "maxItems": 2 + } + }, + + "VectorKeyedEntityListMap": { + "title": "Vector-keyed entity-list map", + "description": "Gson complex-map-key form: an array of [VectorKey, BlueprintEntity[]] pairs. Multiple entities can share the same tile (e.g. a villager and an item frame in one block space).", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/Vector" }, + { + "type": "array", + "items": { "$ref": "#/$defs/BlueprintEntity" } + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + + "InventoryMap": { + "title": "Inventory slot map", + "description": "Map of zero-based inventory slot to ItemStack (YAML-encoded). Keys are stringified integers; absent slots are empty.", + "type": "object", + "propertyNames": { "pattern": "^[0-9]+$" }, + "additionalProperties": { "$ref": "#/$defs/ItemStackYaml" } + }, + + "Blueprint": { + "title": "Blueprint", + "description": "Top-level object written to a `.blueprint` file. Describes a block volume, attached blocks, and entities relative to an anchor (`bedrock`).", + "type": "object", + "properties": { + "name": { + "description": "Unique identifier for this blueprint; used to look it up from a BlueprintBundle. Conventionally matches the filename stem. Non-null; default empty string.", + "type": "string" + }, + "displayName": { + "description": "Human-readable name shown in UIs. May contain legacy `§` colour codes or MiniMessage tags depending on locale conventions.", + "type": "string" + }, + "icon": { "$ref": "#/$defs/Icon" }, + "description": { + "description": "Lore lines shown under the icon in selection UIs.", + "type": "array", + "items": { "type": "string" } + }, + "bedrock": { + "description": "Anchor point of the blueprint. When pasted, blueprint (0,0,0) is translated so `bedrock` lands on the paste target. If omitted at load-time, BentoBox auto-creates one at `(xSize/2, ySize/2, zSize/2)`.", + "$ref": "#/$defs/Vector" + }, + "xSize": { + "description": "Width of the bounding box in blocks (X axis).", + "type": "integer", + "minimum": 0 + }, + "ySize": { + "description": "Height of the bounding box in blocks (Y axis).", + "type": "integer", + "minimum": 0 + }, + "zSize": { + "description": "Depth of the bounding box in blocks (Z axis).", + "type": "integer", + "minimum": 0 + }, + "sink": { + "description": "If true, at paste time the blueprint will descend until it finds a surface, rather than pasting at the exact Y of the anchor.", + "type": "boolean" + }, + "blocks": { + "description": "Primary blocks of the blueprint, keyed by position relative to the blueprint origin (0..size-1 on each axis).", + "$ref": "#/$defs/VectorKeyedBlockMap" + }, + "attached": { + "description": "Blocks that must be pasted AFTER `blocks` because they attach to a supporting block (torches, ladders, rails, beds, doors, signs, etc.). Same coordinate conventions as `blocks`.", + "$ref": "#/$defs/VectorKeyedBlockMap" + }, + "entities": { + "description": "Entities to spawn at each position. The position key is the block the entity resides in; fine-grained in-block offsets live on BlueprintEntity (x/y/z fields). Multiple entities may share a key.", + "$ref": "#/$defs/VectorKeyedEntityListMap" + } + }, + "additionalProperties": false + }, + + "BlueprintBundle": { + "title": "BlueprintBundle", + "description": "A bundle groups up to three blueprints — one per world environment — into a single selectable option in the island-create UI. Persisted as `.json` inside a game mode's `blueprints/` folder (alongside the `.blueprint` files it references).", + "type": "object", + "properties": { + "uniqueId": { + "description": "Unique identifier for this bundle. Must equal the filename stem (e.g. `default` for `default.json`). Used as a permission suffix when `requirePermission` is true.", + "type": "string", + "minLength": 1 + }, + "displayName": { + "description": "Human-readable name for UI display.", + "type": "string" + }, + "icon": { "$ref": "#/$defs/Icon" }, + "description": { + "description": "Lore shown under the icon in the selection UI.", + "type": "array", + "items": { "type": "string" } + }, + "blueprints": { + "description": "Map from world environment to the `name` of a Blueprint present in the same folder. Environments with no entry are not generated for this bundle.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/EnvironmentKey" }, + "additionalProperties": { "type": "string" } + }, + "requirePermission": { + "description": "If true, using this bundle requires the permission `.island.create.`.", + "type": "boolean" + }, + "slot": { + "description": "Preferred slot in the selection GUI (0-based). Slots are clamped at runtime.", + "type": "integer", + "minimum": 0 + }, + "times": { + "description": "Maximum number of islands a single player may create with this bundle. `0` means unlimited.", + "type": "integer", + "minimum": 0 + }, + "cost": { + "description": "Vault-economy cost a player pays to use this bundle. `0` means free. Requires a Vault-compatible economy plugin at runtime.", + "type": "number", + "minimum": 0 + }, + "commands": { + "description": "Commands executed when an island is created with this bundle. Placeholders `[player]` and `[owner]` are substituted. Entries prefixed with `[SUDO]` run as the player; others run as console.", + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["uniqueId"], + "additionalProperties": false + }, + + "BlueprintBlock": { + "title": "BlueprintBlock", + "description": "One block cell of a blueprint. The `blockData` string is required; all other fields are applied only when the block type supports them.", + "type": "object", + "properties": { + "blockData": { + "description": "Bukkit BlockData string, i.e. the output of `BlockData#getAsString()`. Examples: `minecraft:bedrock`, `minecraft:oak_log[axis=y]`, `minecraft:chest[facing=north,type=single,waterlogged=false]`.", + "type": "string", + "minLength": 1 + }, + "signLines": { + "description": "Front-side sign lines (up to 4). Supports legacy `§` colour codes. DEPRECATED since BentoBox 1.24.0 in favour of side-specific serialisation; still written and read for backwards compatibility.", + "type": "array", + "items": { "type": "string" }, + "maxItems": 4 + }, + "signLines2": { + "description": "Back-side sign lines (up to 4). Added in 1.24.0 when dual-sided signs were introduced.", + "type": "array", + "items": { "type": "string" }, + "maxItems": 4 + }, + "glowingText": { + "description": "Whether the front side of this sign has glowing text.", + "type": "boolean" + }, + "glowingText2": { + "description": "Whether the back side of this sign has glowing text.", + "type": "boolean" + }, + "inventory": { + "description": "Container contents (chests, barrels, hoppers, shulker boxes, furnaces, brewing stands, etc.). Keyed by slot index.", + "$ref": "#/$defs/InventoryMap" + }, + "bannerPatterns": { + "description": "Banner pattern layers, applied in order. Each entry is a Bukkit Pattern serialized via ConfigurationSerializable with keys `pattern` (legacy short code, e.g. `bri`) and `color` (DyeColor name).", + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "color": { "$ref": "#/$defs/DyeColor" } + } + } + }, + "biome": { + "description": "Biome override for this specific block cell (Bukkit `Biome` enum name). Optional.", + "type": "string" + }, + "creatureSpawner": { + "description": "Present only when `blockData` is a spawner. Describes the spawner configuration.", + "$ref": "#/$defs/BlueprintCreatureSpawner" + }, + "trialSpawner": { + "description": "Present only when `blockData` is a trial spawner (1.21+). Mutually exclusive with `creatureSpawner`.", + "$ref": "#/$defs/BlueprintTrialSpawner" + }, + "itemsAdderBlock": { + "description": "ItemsAdder custom block namespace identifier (e.g. `myserver:custom_ore`). Only meaningful when the ItemsAdder plugin is installed; otherwise the block falls back to `blockData`.", + "type": "string" + } + }, + "required": ["blockData"], + "additionalProperties": false + }, + + "BlueprintCreatureSpawner": { + "title": "BlueprintCreatureSpawner", + "description": "Vanilla (non-trial) mob spawner configuration.", + "type": "object", + "properties": { + "spawnedType": { "$ref": "#/$defs/EntityType" }, + "delay": { + "description": "Current countdown (ticks) until the next spawn attempt.", + "type": "integer" + }, + "maxNearbyEntities": { + "description": "Spawner will stop spawning while at least this many entities of the spawned type exist within its tracking radius.", + "type": "integer", + "minimum": 0 + }, + "maxSpawnDelay": { + "description": "Upper bound (ticks) of the randomised delay picked after a spawn.", + "type": "integer", + "minimum": 0 + }, + "minSpawnDelay": { + "description": "Lower bound (ticks) of the randomised delay picked after a spawn.", + "type": "integer", + "minimum": 0 + }, + "requiredPlayerRange": { + "description": "Maximum distance (blocks) from which a player keeps the spawner active.", + "type": "integer", + "minimum": 0 + }, + "spawnRange": { + "description": "Radius (blocks) around the spawner within which mobs may spawn.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + + "BlueprintTrialSpawner": { + "title": "BlueprintTrialSpawner", + "description": "Trial spawner configuration (1.21+). Added in BentoBox 3.4.2.", + "type": "object", + "properties": { + "ominous": { + "description": "Whether the spawner is in its ominous (cursed) state.", + "type": "boolean" + }, + "spawnedType": { + "description": "Single entity type to spawn. Use this OR `potentialSpawns`.", + "$ref": "#/$defs/EntityType" + }, + "delay": { "type": "integer" }, + "addSimulEnts": { + "description": "Additional simultaneous entities granted per player (scaling factor).", + "type": "number" + }, + "addSpawnsB4Cool": { + "description": "Additional total spawns granted per player before cooldown.", + "type": "number" + }, + "baseSimEnts": { + "description": "Base number of simultaneous entities the spawner will keep alive.", + "type": "number" + }, + "baseSpawnsB4Cool": { + "description": "Base number of total spawns before entering cooldown.", + "type": "number" + }, + "spawnRange": { "type": "integer", "minimum": 0 }, + "requiredPlayerRange": { "type": "integer", "minimum": 0 }, + "playerRange": { "type": "integer", "minimum": 0 }, + "lootTableMap": { + "description": "Candidate reward loot tables with relative weights. Keyed by a loot-table NamespacedKey object.", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { + "nameSpace": { "type": "string" }, + "key": { "type": "string" } + }, + "required": ["nameSpace", "key"], + "additionalProperties": false + }, + { "type": "integer", "minimum": 1 } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "potentialSpawns": { + "description": "Weighted list of possible spawns. Use this OR `spawnedType`.", + "type": "array", + "items": { + "type": "object", + "properties": { + "snapshot": { + "description": "EntitySnapshot serialised as a string (Bukkit `EntitySnapshot#getAsString`). Opaque to this schema.", + "type": "string" + }, + "spawnrule": { + "description": "Bukkit SpawnRule serialised via ConfigurationSerializable. Keys depend on server version; typically includes block-light / sky-light / min/max Y fields.", + "type": "object", + "additionalProperties": true + }, + "spawnWeight": { + "description": "Relative weight of this candidate.", + "type": "integer", + "minimum": 1 + } + }, + "required": ["spawnWeight"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + + "BlueprintEntity": { + "title": "BlueprintEntity", + "description": "One entity to spawn. Only `type` is required; all other fields are applied only when the Bukkit entity class supports them. Nullable wrappers indicate a tri-state: unset means \"do not modify Bukkit default\".", + "type": "object", + "properties": { + "type": { "$ref": "#/$defs/EntityType" }, + "customName": { + "description": "Custom display name; supports legacy `§` colour codes.", + "type": "string" + }, + "x": { + "description": "Fine offset within the position cell (typically 0.0 ≤ x < 1.0 for relative placement, but absolute values are allowed).", + "type": "number" + }, + "y": { "type": "number" }, + "z": { "type": "number" }, + + "glowing": { "type": "boolean", "description": "Glow effect on the entity." }, + "gravity": { "type": "boolean", "description": "Whether gravity applies." }, + "visualFire": { "type": "boolean", "description": "Render a fire effect regardless of `fireTicks`." }, + "silent": { "type": "boolean", "description": "Suppress ambient sounds." }, + "invulnerable": { "type": "boolean", "description": "Immune to all damage sources." }, + "fireTicks": { "type": "integer", "description": "Remaining fire duration (ticks)." }, + + "adult": { "type": "boolean", "description": "Ageable entities only — set false to spawn as baby." }, + "color": { "$ref": "#/$defs/DyeColor", "description": "Colourable entities only (sheep, shulker, collar colour for wolves, etc.)." }, + "tamed": { "type": "boolean", "description": "Tameable entities only. Owner is not restored." }, + "chest": { "type": "boolean", "description": "Chest-carrying horses and llamas only." }, + "domestication": { "type": "integer", "description": "Horse domestication level.", "minimum": 0, "maximum": 100 }, + "inventory": { "$ref": "#/$defs/InventoryMap", "description": "Horse/llama inventory." }, + + "profession": { "type": "string", "description": "Villager profession (enum name or namespaced key string)." }, + "level": { "type": "integer", "description": "Villager level.", "minimum": 1, "maximum": 5 }, + "experience": { "type": "integer", "description": "Villager experience points." }, + "villagerType": { "type": "string", "description": "Villager biome/type variant (enum name or namespaced key string)." }, + "style": { "type": "string", "description": "Horse coat style — Bukkit `Horse.Style` enum name.", "enum": ["WHITE", "WHITEFIELD", "WHITE_DOTS", "BLACK_DOTS", "NONE"] }, + + "npc": { "type": "string", "description": "Citizens plugin NPC id. Only meaningful when Citizens is installed." }, + "MMtype": { "type": "string", "description": "MythicMobs mob type identifier." }, + "MMLevel": { "type": "number", "description": "MythicMobs mob level." }, + "MMpower": { "type": "number", "description": "MythicMobs mob power." }, + "MMStance":{ "type": "string", "description": "MythicMobs mob stance." }, + + "displayRec": { "$ref": "#/$defs/DisplayRec", "description": "Properties common to all DisplayEntity subtypes." }, + "blockDisp": { "$ref": "#/$defs/BlueprintBlock", "description": "BlockDisplay payload — the displayed block." }, + "itemDisp": { "$ref": "#/$defs/ItemDispRec", "description": "ItemDisplay payload." }, + "textDisp": { "$ref": "#/$defs/TextDisplayRec","description": "TextDisplay payload." }, + "itemFrame": { "$ref": "#/$defs/ItemFrameRec", "description": "ItemFrame payload (since BentoBox 3.2.6)." } + }, + "required": ["type"], + "additionalProperties": false + }, + + "DisplayRec": { + "title": "DisplayRec", + "description": "Properties shared by all Display entities (BlockDisplay, ItemDisplay, TextDisplay).", + "type": "object", + "properties": { + "billboard": { + "type": "string", + "enum": ["FIXED", "VERTICAL", "HORIZONTAL", "CENTER"], + "description": "How the display rotates to face the viewer." + }, + "brightness": { + "description": "Bukkit Display.Brightness — typically `{\"block\": int, \"sky\": int}` via ConfigurationSerializable. Opaque.", + "type": "object", + "additionalProperties": true + }, + "height": { "type": "number" }, + "width": { "type": "number" }, + "glowColorOverride": { "$ref": "#/$defs/Color" }, + "interpolationDelay": { "type": "integer" }, + "interpolationDuration": { "type": "integer" }, + "shadowRadius": { "type": "number" }, + "shadowStrength": { "type": "number" }, + "teleportDuration": { "type": "integer" }, + "transformation": { + "description": "Bukkit Transformation (translation, leftRotation, scale, rightRotation) via ConfigurationSerializable. Opaque.", + "type": "object", + "additionalProperties": true + }, + "range": { "type": "number" } + }, + "additionalProperties": false + }, + + "ItemDispRec": { + "type": "object", + "properties": { + "item": { "$ref": "#/$defs/ItemStackYaml" }, + "itemDispTrans": { + "type": "string", + "description": "Bukkit ItemDisplay.ItemDisplayTransform enum name.", + "enum": [ + "NONE", + "THIRDPERSON_LEFTHAND", "THIRDPERSON_RIGHTHAND", + "FIRSTPERSON_LEFTHAND", "FIRSTPERSON_RIGHTHAND", + "HEAD", "GUI", "GROUND", "FIXED" + ] + } + }, + "additionalProperties": false + }, + + "TextDisplayRec": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "Displayed text. Legacy `§` colour codes accepted." }, + "alignment": { + "type": "string", + "enum": ["CENTER", "LEFT", "RIGHT"] + }, + "bgColor": { "$ref": "#/$defs/Color" }, + "face": { "$ref": "#/$defs/BlockFace" }, + "lWidth": { "type": "integer", "description": "Line-wrap width in pixels." }, + "opacity": { "type": "integer", "minimum": -128, "maximum": 127, "description": "Signed byte: -1 renders the default opacity." }, + "isShadowed": { "type": "boolean" }, + "isSeeThrough": { "type": "boolean" }, + "isDefaultBg": { "type": "boolean" } + }, + "additionalProperties": false + }, + + "ItemFrameRec": { + "type": "object", + "properties": { + "item": { "$ref": "#/$defs/ItemStackYaml" }, + "rotation": { + "type": "string", + "description": "Bukkit Rotation enum name.", + "enum": [ + "NONE", + "CLOCKWISE_45", "CLOCKWISE", "CLOCKWISE_135", + "FLIPPED", "FLIPPED_45", + "COUNTER_CLOCKWISE_45", "COUNTER_CLOCKWISE", "COUNTER_CLOCKWISE_135" + ] + }, + "isFixed": { "type": "boolean", "description": "Immovable and unrotatable when true." }, + "isVisible": { "type": "boolean" }, + "dropChance": { "type": "number", "minimum": 0.0, "maximum": 1.0 } + }, + "additionalProperties": false + }, + + "DyeColor": { + "description": "Bukkit DyeColor enum name.", + "type": "string", + "enum": [ + "WHITE", "ORANGE", "MAGENTA", "LIGHT_BLUE", + "YELLOW", "LIME", "PINK", "GRAY", + "LIGHT_GRAY", "CYAN", "PURPLE", "BLUE", + "BROWN", "GREEN", "RED", "BLACK" + ] + }, + + "BlockFace": { + "description": "Bukkit BlockFace enum name.", + "type": "string", + "enum": [ + "NORTH", "EAST", "SOUTH", "WEST", "UP", "DOWN", + "NORTH_EAST", "NORTH_WEST", "SOUTH_EAST", "SOUTH_WEST", + "WEST_NORTH_WEST", "NORTH_NORTH_WEST", "NORTH_NORTH_EAST", "EAST_NORTH_EAST", + "EAST_SOUTH_EAST", "SOUTH_SOUTH_EAST", "SOUTH_SOUTH_WEST", "WEST_SOUTH_WEST", + "SELF" + ] + }, + + "EntityType": { + "description": "Bukkit EntityType enum name (e.g. `VILLAGER`, `ZOMBIE`, `ARMOR_STAND`, `ITEM_FRAME`, `ITEM_DISPLAY`, `BLOCK_DISPLAY`, `TEXT_DISPLAY`, `TRIAL_SPAWNER`). The exact set depends on the server version.", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + } +} diff --git a/src/api/submissions.ts b/src/api/submissions.ts new file mode 100644 index 0000000..4463ba3 --- /dev/null +++ b/src/api/submissions.ts @@ -0,0 +1,364 @@ +import { RequestHandler } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Octokit } from 'octokit'; +import multer = require('multer'); +import Ajv, { ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; +import { AuthedRequest, AuthManager, TERMS_VERSION } from './auth'; +import { CatalogOverlay, computeStats, parseBlueprintId } from './blueprintCatalog'; +import { WeblinkSync } from './weblink'; +import schema from './schemas/blueprint.schema.json'; +import { validateBlueprintSubmission } from './blueprintCatalog'; + +// 5 MB cap. +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE, files: 1, fields: 20 }, +}); + +interface SubmissionsConfig { + weblinkRepo: { owner: string; repo: string }; // BentoBoxWorld/weblink + weblinkToken: string; // env.weblink_github_token + weblinkBranch: string; // master +} + +export class SubmissionManager { + private validate: ValidateFunction; + private octokit: Octokit | null; + + constructor( + private readonly auth: AuthManager, + private readonly weblink: WeblinkSync, + private readonly cfg: SubmissionsConfig | null, + ) { + const ajv = new Ajv({ allErrors: false, strict: false }); + addFormats(ajv); + // The full schema accepts either Blueprint or BlueprintBundle; for + // submissions we restrict to Blueprint. Register the full schema so + // $ref resolution works, then compile against the Blueprint subschema. + ajv.addSchema(schema as object, schema.$id); + this.validate = ajv.compile({ + $ref: `${schema.$id}#/$defs/Blueprint`, + }); + + this.octokit = cfg ? new Octokit({ auth: cfg.weblinkToken }) : null; + } + + isConfigured(): boolean { + return !!this.cfg && !!this.octokit; + } + + handleSubmit: RequestHandler = async (req, res) => { + const ar = req as AuthedRequest; + if (!ar.session || !ar.user) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + if (!this.cfg || !this.octokit) { + res.status(503).json({ error: 'submissions disabled', reason: 'weblink_github_token not configured' }); + return; + } + + // multer attaches the file to req.file and form fields to req.body. + type WithFile = typeof req & { + file?: Express.Multer.File; + body: Record; + }; + const r = req as WithFile; + if (!r.file) { + res.status(400).json({ error: 'no_file', reason: 'Missing blueprint file.' }); + return; + } + + const gameMode = (r.body.gameMode || '').trim(); + const name = (r.body.name || '').trim(); + const displayName = (r.body.displayName || '').trim(); + const description = parseDescription(r.body.description); + const tags = parseTags(r.body.tags); + const acceptedTerms = r.body.acceptedTerms === 'true' || r.body.acceptedTerms === 'on'; + + // 1. Required fields. + if (!gameMode || !name || !displayName) { + res.status(400).json({ error: 'missing_fields' }); + return; + } + if (!acceptedTerms) { + res.status(400).json({ error: 'terms_not_accepted' }); + return; + } + + // 2. Slug regex (matches the same one used for path resolution). + if (!parseBlueprintId(`${gameMode}/${name}`)) { + res.status(400).json({ error: 'bad_id', reason: 'gameMode/name has invalid characters.' }); + return; + } + + // 3. Rate limit. + const quota = this.auth.consumeSubmissionQuota(ar.user.id); + if (!quota.ok) { + res.status(429).json({ error: 'rate_limited', reason: quota.reason }); + return; + } + + // 4. JSON parse. + let parsed: unknown; + try { + parsed = JSON.parse(r.file.buffer.toString('utf-8')); + } catch { + res.status(400).json({ error: 'invalid_json', reason: 'Blueprint is not valid JSON.' }); + return; + } + + // 5. Schema validation. + if (!this.validate(parsed)) { + const errors = (this.validate.errors ?? []).slice(0, 3).map( + (e) => `${e.instancePath || '/'}: ${e.message ?? 'invalid'}`, + ); + res.status(400).json({ error: 'schema_failed', issues: errors }); + return; + } + + // 6. Sanitiser. + const sanitised = validateBlueprintSubmission(parsed); + if (!sanitised.ok) { + res.status(400).json({ + error: 'sanitiser_rejected', + issues: sanitised.issues.map((i) => `${i.field}: ${i.reason}`), + }); + return; + } + + // 7. Local slug collision check (against the cached weblink clone). + const localTarget = path.join(this.weblink.blueprintsDir, gameMode, `${name}.blueprint`); + if (fs.existsSync(localTarget)) { + res.status(409).json({ error: 'name_taken', reason: `${gameMode}/${name} already exists.` }); + return; + } + + // 8. Stats summary for the PR body. + const bp = parsed as Parameters[0]; + const stats = computeStats(bp); + + // 9. Mark terms accepted. + await this.auth.recordTermsAccepted(ar.user.id, TERMS_VERSION); + + // 10. Open the PR. + try { + const pretty = JSON.stringify(parsed, null, 2); + const updatedCatalog = await this.patchedCatalog({ + id: `${gameMode}/${name}`, + displayName, + description, + authorDiscordId: ar.user.id, + authorUsername: r.body.username || '', + tags, + }); + + const prUrl = await this.openPullRequest({ + discordUserId: ar.user.id, + gameMode, + name, + displayName, + statsSummary: summariseStats(stats, displayName, gameMode, name), + blueprintBody: pretty, + catalogBody: updatedCatalog, + }); + + // Record locally. + await this.auth.submissions.create({ + userId: ar.user.id, + gameMode, + name, + displayName, + prUrl, + termsVersion: TERMS_VERSION, + createdAt: Date.now(), + } as never); + + res.json({ ok: true, prUrl }); + } catch (err) { + console.error('[submission] PR creation failed:', (err as Error).message); + res.status(502).json({ error: 'pr_failed', reason: 'Could not open pull request on weblink.' }); + } + }; + + handleMySubmissions: RequestHandler = async (req, res) => { + const ar = req as AuthedRequest; + if (!ar.user) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + const rows = await this.auth.submissions.findAll({ + where: { userId: ar.user.id }, + order: [['createdAt', 'DESC']], + limit: 50, + }); + res.json( + rows.map((r) => ({ + gameMode: r.gameMode, + name: r.name, + displayName: r.displayName, + prUrl: r.prUrl, + createdAt: r.createdAt, + })), + ); + }; + + // ----- GitHub plumbing ----- + + private async patchedCatalog(entry: { + id: string; + displayName: string; + description: string[]; + authorDiscordId: string; + authorUsername: string; + tags: string[]; + }): Promise { + // Read current catalog.json from the weblink clone (or default to empty). + const catalogPath = path.join(this.weblink.blueprintsDir, 'catalog.json'); + let overlay: CatalogOverlay = {}; + if (fs.existsSync(catalogPath)) { + try { + overlay = JSON.parse(fs.readFileSync(catalogPath, 'utf-8')); + } catch { + /* fall through to empty */ + } + } + if (!overlay.blueprints) overlay.blueprints = {}; + overlay.blueprints[entry.id] = { + displayName: entry.displayName, + description: entry.description, + author: entry.authorUsername || `Discord:${entry.authorDiscordId}`, + authorLink: `https://discord.com/users/${entry.authorDiscordId}`, + tags: entry.tags.length ? entry.tags : undefined, + license: 'EPL-2.0', + }; + return JSON.stringify(overlay, null, 2) + '\n'; + } + + private async openPullRequest(args: { + discordUserId: string; + gameMode: string; + name: string; + displayName: string; + statsSummary: string; + blueprintBody: string; + catalogBody: string; + }): Promise { + if (!this.cfg || !this.octokit) throw new Error('submissions disabled'); + const { owner, repo } = this.cfg.weblinkRepo; + const baseBranch = this.cfg.weblinkBranch; + + // Get base branch SHA. + const baseRef = await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${baseBranch}`, + }); + const baseSha = baseRef.data.object.sha; + + // Branch name. Timestamp avoids collision when a user resubmits. + const branch = `submit/${args.discordUserId}/${args.gameMode}-${args.name}-${Date.now()}`; + await this.octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branch}`, + sha: baseSha, + }); + + // Commit blueprint. + await this.octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + branch, + path: `blueprints/${args.gameMode}/${args.name}.blueprint`, + message: `Add blueprint ${args.gameMode}/${args.name}`, + content: Buffer.from(args.blueprintBody, 'utf-8').toString('base64'), + }); + + // Commit catalog (need its current SHA to update in place). + let catalogSha: string | undefined; + try { + const existing = await this.octokit.rest.repos.getContent({ + owner, + repo, + path: 'blueprints/catalog.json', + ref: branch, + }); + if (!Array.isArray(existing.data) && existing.data.type === 'file') { + catalogSha = existing.data.sha; + } + } catch { + /* no catalog yet */ + } + await this.octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + branch, + path: 'blueprints/catalog.json', + message: `Update catalog for ${args.gameMode}/${args.name}`, + content: Buffer.from(args.catalogBody, 'utf-8').toString('base64'), + sha: catalogSha, + }); + + // Open PR. + const pr = await this.octokit.rest.pulls.create({ + owner, + repo, + head: branch, + base: baseBranch, + title: `Submission: ${args.displayName} (${args.gameMode}/${args.name})`, + body: + `Submitted via the Downloads site by Discord user \`${args.discordUserId}\`.\n\n` + + `Terms accepted: ${TERMS_VERSION}\n\n` + + `### Stats\n\n${args.statsSummary}\n`, + }); + return pr.data.html_url; + } +} + +// ----- helpers ----- + +function parseDescription(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .slice(0, 10); +} + +function parseTags(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => /^[a-z0-9_-]{2,24}$/.test(t)) + .slice(0, 8); +} + +function summariseStats( + s: ReturnType, + displayName: string, + gameMode: string, + name: string, +): string { + const lines: string[] = []; + lines.push(`- **Display name:** ${displayName}`); + lines.push(`- **Path:** \`${gameMode}/${name}.blueprint\``); + lines.push(`- **Dimensions:** ${s.dimensions.x} × ${s.dimensions.y} × ${s.dimensions.z}`); + lines.push(`- **Blocks:** ${s.blockCount}, attached: ${s.attachedCount}, air: ${s.airCount}`); + lines.push(`- **Entities:** ${s.entityCount}`); + if (s.biomes.length) lines.push(`- **Biomes:** ${s.biomes.join(', ')}`); + if (s.sinking) lines.push(`- **Sinking:** yes`); + if (s.topBlocks.length) { + lines.push(`- **Top blocks:** ${s.topBlocks.map((b) => `${b.material}×${b.count}`).join(', ')}`); + } + if (s.topEntities.length) { + lines.push(`- **Top entities:** ${s.topEntities.map((e) => `${e.type}×${e.count}`).join(', ')}`); + } + return lines.join('\n'); +} diff --git a/src/index.ts b/src/index.ts index a55886f..fbde44a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ import * as express from 'express'; import apiClass from './api/api'; import * as fs from 'fs'; +import * as path from 'path'; import * as mime from 'mime-types'; import helmet from 'helmet'; +import cookieParser = require('cookie-parser'); import { ConfigObject } from './config'; +import { upload as blueprintUpload } from './api/submissions'; import * as https from 'https'; import axios from 'axios'; @@ -19,23 +22,115 @@ fs.readdir('web', (err, files) => { }); }); -let env: { github_token?: string; github_downloads?: number; discord_error_webhook_url?: string; port?: number } = {}; +let env: { + github_token?: string; + github_downloads?: number; + discord_error_webhook_url?: string; + port?: number; +} = {}; try { env = require('./../env.json'); } catch (e) {} const port = env.port || 8080; -app.use(helmet()); +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'https://cdn.discordapp.com', 'data:'], + connectSrc: ["'self'"], + scriptSrc: ["'self'"], + // Discord OAuth uses top-level navigation; helmet's CSP does + // not constrain navigation, so no entry for discord.com is + // needed here. + }, + }, + }), +); +app.use(cookieParser()); app.set('X-Powered-By', 'BentoBox'); +// Auth: load session for every /api request, then expose discrete routes. +const wrap = (fn: (...a: unknown[]) => unknown) => + (req: express.Request, res: express.Response, next: express.NextFunction) => + fn(req, res, next); + +app.use('/api', wrap(apiManager.auth.loadSession as (...a: unknown[]) => unknown)); +app.get('/api/auth/discord/login', wrap(apiManager.auth.handleLogin as (...a: unknown[]) => unknown)); +app.get( + '/api/auth/discord/callback', + wrap(apiManager.auth.callbackRateLimit as (...a: unknown[]) => unknown), + wrap(apiManager.auth.handleCallback as (...a: unknown[]) => unknown), +); +app.post( + '/api/auth/logout', + express.json(), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.handleLogout as (...a: unknown[]) => unknown), +); +app.get('/api/me', wrap(apiManager.auth.handleMe as (...a: unknown[]) => unknown)); +app.post( + '/api/me/delete', + express.json(), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.handleDeleteMe as (...a: unknown[]) => unknown), +); + +// Blueprint submission: multer parses multipart/form-data; CSRF enforced. +// Cast multer middleware to a plain handler — multer ships its own copy +// of @types/express which collides with the project's. +const multerSingle = blueprintUpload.single('blueprint') as unknown as ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => void; + +app.post( + '/api/blueprints/submit', + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + multerSingle, + wrap(apiManager.submissions.handleSubmit as (...a: unknown[]) => unknown), +); +app.get( + '/api/me/submissions', + wrap(apiManager.auth.requireSession as (...a: unknown[]) => unknown), + wrap(apiManager.submissions.handleMySubmissions as (...a: unknown[]) => unknown), +); + app.get('/api/*', function (req, res) { apiManager.manageRequest(req, res); }); +app.get('/blueprints/images/*', function (req, res) { + // PNG-only static handler for the cloned weblink/blueprints/images tree. + // Path-traversal guarded; everything else 404s. + const rel = req.url.slice('/blueprints/images/'.length).split('?')[0].split('#')[0]; + if (!/^[A-Za-z0-9._/-]+\.(png|PNG)$/.test(rel) || rel.includes('..')) { + res.status(404).end(); + return; + } + const root = path.resolve(apiManager.weblink.blueprintsDir, 'images'); + const target = path.resolve(root, rel); + if (!target.startsWith(root + path.sep)) { + res.status(404).end(); + return; + } + fs.stat(target, (err, stat) => { + if (err || !stat.isFile()) { + res.status(404).end(); + return; + } + res.set('Content-Type', 'image/png'); + res.set('Cache-Control', 'public, max-age=600'); + fs.createReadStream(target).pipe(res); + }); +}); + app.get('*', function (req, res) { - res.set('Content-Security-Policy', "style-src 'self' 'unsafe-inline'"); if (publicFiles.has(req.url.slice(1))) { res.set('Content-Type', mime.lookup(req.url.slice(1)) || ''); switch (mime.lookup(req.url.slice(1))) { diff --git a/src/web/ApiRequestManager.ts b/src/web/ApiRequestManager.ts index ee77c8f..880b93e 100644 --- a/src/web/ApiRequestManager.ts +++ b/src/web/ApiRequestManager.ts @@ -28,3 +28,74 @@ export function BlueprintZipUrl(ids: string[]): string { export function BlueprintGameModeZipUrl(gameMode: string): string { return `/api/blueprints/zip?gameMode=${encodeURIComponent(gameMode)}`; } + +export interface SessionUser { + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; + acceptedTermsVersion: string | null; + csrfToken: string; + currentTermsVersion: string; +} + +export async function GetMe(): Promise { + try { + const r = await axios.get('/api/me'); + return r.data; + } catch (err) { + const e = err as { response?: { status?: number } }; + if (e.response && e.response.status === 401) return null; + throw err; + } +} + +export interface MySubmission { + gameMode: string; + name: string; + displayName: string; + prUrl: string; + createdAt: number; +} + +export async function GetMySubmissions(): Promise { + return (await axios.get('/api/me/submissions')).data; +} + +export async function PostLogout(csrfToken: string): Promise { + await axios.post('/api/auth/logout', null, { headers: { 'X-Csrf-Token': csrfToken } }); +} + +export async function PostDeleteAccount(csrfToken: string): Promise { + await axios.post('/api/me/delete', null, { headers: { 'X-Csrf-Token': csrfToken } }); +} + +export interface SubmitResult { + ok: true; + prUrl: string; +} + +export interface SubmitError { + error: string; + reason?: string; + issues?: string[]; +} + +export async function PostSubmitBlueprint( + csrfToken: string, + fields: { gameMode: string; name: string; displayName: string; description: string; tags: string }, + file: File, +): Promise { + const fd = new FormData(); + fd.append('gameMode', fields.gameMode); + fd.append('name', fields.name); + fd.append('displayName', fields.displayName); + fd.append('description', fields.description); + fd.append('tags', fields.tags); + fd.append('acceptedTerms', 'true'); + fd.append('blueprint', file); + const r = await axios.post('/api/blueprints/submit', fd, { + headers: { 'X-Csrf-Token': csrfToken }, + }); + return r.data; +} diff --git a/src/web/components/Account.tsx b/src/web/components/Account.tsx new file mode 100644 index 0000000..30b0877 --- /dev/null +++ b/src/web/components/Account.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { + GetMe, + GetMySubmissions, + MySubmission, + PostDeleteAccount, + PostLogout, + SessionUser, +} from '../ApiRequestManager'; + +export default function AccountPage() { + const [user, setUser] = useState(undefined); + const [submissions, setSubmissions] = useState(null); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [deleted, setDeleted] = useState(false); + + useEffect(() => { + GetMe() + .then((u) => { + setUser(u); + if (u) GetMySubmissions().then(setSubmissions).catch(() => setSubmissions([])); + }) + .catch(() => setUser(null)); + }, []); + + if (deleted) { + return ( +
    +

    Account deleted

    +

    Thanks for stopping by.

    +
    + ); + } + + if (user === undefined) { + return
    Loading...
    ; + } + if (user === null) { + return ( +
    +

    You are not logged in.

    + + Login with Discord + +
    + ); + } + + async function handleLogout() { + try { + await PostLogout(user!.csrfToken); + } finally { + setUser(null); + } + } + + async function handleDelete() { + setBusy(true); + try { + await PostDeleteAccount(user!.csrfToken); + setDeleted(true); + setUser(null); + } finally { + setBusy(false); + } + } + + return ( +
    +

    Account

    + +
    + {user.avatarUrl && } +
    +
    {user.globalName || user.username}
    +
    Discord ID: {user.id}
    +
    + Terms accepted:{' '} + {user.acceptedTermsVersion + ? user.acceptedTermsVersion === user.currentTermsVersion + ? `${user.acceptedTermsVersion} (current)` + : `${user.acceptedTermsVersion} (out of date)` + : 'not yet'} +
    +
    + +
    + +
    +

    Your submissions

    + {submissions === null &&
    Loading…
    } + {submissions && submissions.length === 0 && ( +
    No submissions yet.
    + )} + {submissions && submissions.length > 0 && ( +
      + {submissions.map((s, i) => ( +
    • + + {s.gameMode}/{s.name} + {' '} + — {s.displayName}{' '} + + ({new Date(s.createdAt).toLocaleDateString()}) + +
    • + ))} +
    + )} +
    + +
    +

    Delete account

    +

    + Removes your user record, all sessions, and the local index of your submissions. Pull requests + already opened on weblink will not be retracted — see the{' '} + + Privacy Policy + + . +

    + {!confirming ? ( + + ) : ( +
    + Are you sure? + + +
    + )} +
    +
    + ); +} diff --git a/src/web/components/Blueprints.tsx b/src/web/components/Blueprints.tsx index d5fcb96..5583264 100644 --- a/src/web/components/Blueprints.tsx +++ b/src/web/components/Blueprints.tsx @@ -68,7 +68,7 @@ function BlueprintCard({ {bp.image ? ( {bp.displayName} @@ -244,8 +244,10 @@ export default function BlueprintsPage({ data }: { data: BlueprintCatalog }) {

    Served from  - BentoBoxWorld/weblink. Submissions and - user accounts are coming soon. + BentoBoxWorld/weblink.  + + Submit a blueprint +

    diff --git a/src/web/components/ContentBox.tsx b/src/web/components/ContentBox.tsx index 31ee18b..465d00a 100644 --- a/src/web/components/ContentBox.tsx +++ b/src/web/components/ContentBox.tsx @@ -12,6 +12,10 @@ const PresetsPage = React.lazy(() => import('./PresetsPage')); const CustomPage = React.lazy(() => import('./CustomPage')); const ThirdPartyPage = React.lazy(() => import('./ThirdParty')); const BlueprintsPage = React.lazy(() => import('./Blueprints')); +const SubmitPage = React.lazy(() => import('./Submit')); +const TermsPage = React.lazy(() => import('./Terms')); +const PrivacyPage = React.lazy(() => import('./Privacy')); +const AccountPage = React.lazy(() => import('./Account')); export default function ContentBox() { function CustomElement() { @@ -84,6 +88,26 @@ export default function ContentBox() { + + }> + + + + + }> + + + + + }> + + + + + }> + + + diff --git a/src/web/components/Privacy.tsx b/src/web/components/Privacy.tsx new file mode 100644 index 0000000..aee04d4 --- /dev/null +++ b/src/web/components/Privacy.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import tw from 'twin.macro'; + +export default function PrivacyPage() { + return ( +
    +

    Privacy Policy

    +

    Last updated: 2026-04-24

    + +
    +
      +
    • Your Discord user ID, username, current display name, and avatar hash (for attribution).
    • +
    • A session identifier linked to your account, with creation/expiry timestamps.
    • +
    • + For each submission you make: your Discord user ID, the submission timestamp, the version of + the Terms you accepted, and the URL of the resulting pull request on{' '} + + BentoBoxWorld/weblink + + . +
    • +
    +

    + We do not request or store your email address. Discord login uses the{' '} + identify scope only. We do not request or store any other Discord data (servers, + messages, friends, etc.). +

    +
    + +
    +

    + Your Discord user ID is included in the body of the public pull request opened when you submit a + blueprint, so a maintainer can credit you and contact you on Discord for clarification. The pull + request and its history live on GitHub and are subject to GitHub’s own terms. +

    +

    We do not share or sell your information for any other purpose.

    +
    + +
    +

    + You may delete your account at any time from the  + + Account + +  page. Deletion removes your user record, all sessions, and the local index of submissions + you have made. It does not retract pull requests already opened on weblink, since + those are public artefacts hosted on GitHub. If you need a pull request itself removed, contact us + on the BentoBox Discord and we will do our best to accommodate the request. +

    +

    + California residents have the right under the CCPA to know what personal information we hold and + to request its deletion. The two paragraphs above describe our complete data holdings and our + deletion process. +

    +
    + +
    +

    + We set one essential, HttpOnly, signed session cookie when you log in, plus a short-lived cookie + holding the OAuth state for the duration of the Discord login redirect. We do not use analytics or + tracking cookies. +

    +
    +
    + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +

    {title}

    + {children} +
    + ); +} diff --git a/src/web/components/Submit.tsx b/src/web/components/Submit.tsx new file mode 100644 index 0000000..1f3ca0d --- /dev/null +++ b/src/web/components/Submit.tsx @@ -0,0 +1,295 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import tw from 'twin.macro'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDiscord } from '@fortawesome/free-brands-svg-icons'; +import { faSignOutAlt, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { + GetBlueprints, + GetMe, + PostLogout, + PostSubmitBlueprint, + SessionUser, + SubmitError, +} from '../ApiRequestManager'; +import { BlueprintCatalog } from '../../config'; + +const NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; + +export default function SubmitPage() { + const [user, setUser] = useState(undefined); + const [catalog, setCatalog] = useState(null); + + useEffect(() => { + GetMe() + .then(setUser) + .catch(() => setUser(null)); + GetBlueprints() + .then(setCatalog) + .catch(() => setCatalog(null)); + }, []); + + if (user === undefined) { + return
    Loading...
    ; + } + if (user === null) { + return ; + } + return setUser(null)} />; +} + +function LoginGate() { + return ( +
    +

    Submit a Blueprint

    +

    + Sign in with Discord to submit a blueprint. Your submission becomes a pull request on the BentoBoxWorld + weblink repo for review. By submitting, you agree to our  + + Terms + +  and  + + Privacy Policy + + . +

    + + +  Login with Discord + +
    + ); +} + +function SubmitForm({ + user, + catalog, + onLogout, +}: { + user: SessionUser; + catalog: BlueprintCatalog | null; + onLogout: () => void; +}) { + const gameModeOptions = useMemo(() => { + const fromCatalog = catalog + ? [ + ...new Set([ + ...Object.keys(catalog.gameModes || {}), + ...catalog.blueprints.map((b) => b.gameMode), + ]), + ] + : []; + return fromCatalog.sort(); + }, [catalog]); + + const [gameMode, setGameMode] = useState(''); + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState(''); + const [file, setFile] = useState(null); + const [accepted, setAccepted] = useState(false); + const [busy, setBusy] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + + async function handleLogout() { + try { + await PostLogout(user.csrfToken); + } finally { + onLogout(); + } + } + + function handleNameChange(e: React.ChangeEvent) { + setName(e.target.value); + } + + const nameValid = NAME_REGEX.test(name); + const canSubmit = + !busy && !!gameMode && nameValid && !!displayName && !!file && accepted; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!file || !canSubmit) return; + setBusy(true); + setError(null); + setSuccess(null); + try { + const result = await PostSubmitBlueprint( + user.csrfToken, + { gameMode, name, displayName, description, tags }, + file, + ); + setSuccess(result.prUrl); + } catch (err) { + const e = err as { response?: { data?: SubmitError } }; + setError(e.response?.data || { error: 'unknown', reason: 'Could not submit. Please try again.' }); + } finally { + setBusy(false); + } + } + + return ( +
    +
    +

    Submit a Blueprint

    +
    + {user.avatarUrl && ( + + )} + {user.globalName || user.username} + +
    +
    + + {success && ( +
    + Submitted. Pull request opened:  + + {success} + +

    + A maintainer will review and merge it. Thank you! +

    +
    + )} + + {error && ( +
    +
    Submission rejected: {error.error}
    + {error.reason &&
    {error.reason}
    } + {error.issues && ( +
      + {error.issues.map((i, idx) => ( +
    • {i}
    • + ))} +
    + )} +
    + )} + +
    + + + + + + + {!nameValid && name.length > 0 && ( +
    + Must match {`/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`} +
    + )} +
    + + + setDisplayName(e.target.value)} + css={tw`w-full border rounded p-1`} + placeholder="Cherry Grove Island" + /> + + + +