diff --git a/.gitignore b/.gitignore index a4d58a4..527c99f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,16 @@ yarn-error.log .idea .env env.json -jarcache.sqlite -downloads.sqlite +*.sqlite *.old* /.project + +# macOS Finder metadata +.DS_Store + +# Runtime data (Auth DB, the auto-cloned weblink mirror, etc.) +/data/ + +# Design hand-off bundles dropped at the repo root +/design_handoff_*/ +/BentoBox Download Site*.zip diff --git a/CLAUDE.md b/CLAUDE.md index e9501c2..ae0158d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,15 +30,30 @@ 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`) +- 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/`): - `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. +- 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. + +**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, `SameSite=Lax`, `Secure` in production, and carries an opaque server-generated session id. 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 - All addon metadata is configuration-driven via `config.json`; adding an addon means editing that file @@ -49,4 +64,93 @@ 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`. +GitHub Actions (`.github/workflows/build.yml`) runs on push/PR to `develop`: tests Node.js 20.x/22.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/` diff --git a/env.example.json b/env.example.json index 01aa66c..136b642 100644 --- a/env.example.json +++ b/env.example.json @@ -2,5 +2,21 @@ "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" + }, + "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", + "admin_discord_ids": ["REPLACE_WITH_DISCORD_USER_ID"], + "admin_github": { + "owner": "BentoBoxWorld", + "repo": "Downloads", + "branch": "develop", + "token": "github_pat_REPLACE_ME" + } } diff --git a/package.json b/package.json index 2618f28..ce635d2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "tsc --project tsconfig.compile.json && webpack --config webpack.config.cjs --env env=production", "start": "cd dist && node index.js", "dev": "yarn clean && tsc --project tsconfig.compile.json && webpack --config webpack.config.cjs --env env=development && yarn start", - "site": "webpack --config webpack.config.cjs --env env=development && yarn start" + "site": "webpack --config webpack.config.cjs --env env=development && yarn start", + "prime-cache": "cd dist && node scripts/prime-cache.js" }, "keywords": [], "author": "Fredthedoggy", @@ -25,20 +26,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 +56,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 +70,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/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..64fc51a --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,535 @@ +import { RequestHandler } from 'express'; +import { Octokit } from 'octokit'; +import { AddonsEntity, ConfigObject, PresetsEntity } from '../config'; +import { AuthedRequest, AuthManager } from './auth'; +import { ConfigScope, ConfigStore } from './configStore'; + +export interface AdminGithubConfig { + owner: string; + repo: string; + branch: string; + token: string; +} + +const DISCORD_ID_RE = /^[0-9]{15,25}$/; +const COLOR_RE = /^#[0-9a-fA-F]{3,8}$/; +const ADDON_NAME_RE = /^[A-Za-z0-9 _-]+$/; +const GITHUB_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +// Permissive: real config has entries like "1.16-Java16" and "1.11.0.2", +// and future Minecraft is moving to a different scheme entirely. +const MC_VERSION_RE = /^[A-Za-z0-9._-]{1,32}$/; +const MAX_PRESETS = 50; +const MAX_NAME_LEN = 80; +const MAX_TEXT_LEN = 2000; +const MAX_DESCRIPTION_LEN = 4000; +const MAX_ADDONS_PER_PRESET = 50; +const MAX_VERSION_VALUE_LEN = 50; +const MAX_VERSIONS_PER_ADDON = 50; +const PROTECTED_ADDON_NAMES = new Set(['BentoBox']); + +const VALID_SCOPES: ConfigScope[] = ['addons', 'presets']; + +export class AdminManager { + private readonly octokit: Octokit | null; + + constructor( + private readonly auth: AuthManager, + private readonly configStore: ConfigStore, + private readonly reload: () => Promise, + private readonly github: AdminGithubConfig | null, + ) { + this.octokit = github ? new Octokit({ auth: github.token }) : null; + } + + handleListAdmins: RequestHandler = async (_req, res) => { + const admins = await this.auth.listAdmins(); + res.json(admins); + }; + + handleSetAdmin: RequestHandler = async (req, res) => { + const body = (req.body ?? {}) as { discordId?: unknown; isAdmin?: unknown }; + const discordId = typeof body.discordId === 'string' ? body.discordId.trim() : ''; + const isAdmin = body.isAdmin === true; + + if (!DISCORD_ID_RE.test(discordId)) { + res.status(400).json({ error: 'invalid_discord_id' }); + return; + } + if (typeof body.isAdmin !== 'boolean') { + res.status(400).json({ error: 'invalid_is_admin' }); + return; + } + + const actor = (req as AuthedRequest).user; + if (actor && actor.id === discordId && !isAdmin) { + res.status(400).json({ error: 'cannot_demote_self' }); + return; + } + + await this.auth.setAdmin(discordId, isAdmin); + res.json({ ok: true }); + }; + + handleListPresets: RequestHandler = async (_req, res) => { + res.json(this.configStore.getEffectiveConfig().presets); + }; + + handleUpdatePresets: RequestHandler = async (req, res) => { + const result = validatePresets( + req.body, + new Set(this.configStore.getEffectiveConfig().addons.map((a) => a.name)), + ); + if (!result.ok) { + res.status(400).json({ error: 'invalid_presets', issues: result.issues }); + return; + } + + const actor = (req as AuthedRequest).user; + await this.configStore.setOverride( + 'presets', + result.value, + actor?.id ?? '', + `updated presets list (${result.value.length} entries)`, + ); + await this.reload(); + res.json({ ok: true }); + }; + + handleListAddons: RequestHandler = async (_req, res) => { + res.json(this.configStore.getEffectiveConfig().addons); + }; + + handleCreateAddon: RequestHandler = async (req, res) => { + const result = validateAddon(req.body); + if (!result.ok) { + res.status(400).json({ error: 'invalid_addon', issues: result.issues }); + return; + } + const current = this.configStore.getEffectiveConfig().addons; + if (current.some((a) => a.name === result.value.name)) { + res.status(409).json({ error: 'name_taken' }); + return; + } + const next = [...current, result.value]; + await this.persistAddons(next, req, `created addon "${result.value.name}"`); + res.json({ ok: true, addon: result.value }); + }; + + handleUpdateAddon: RequestHandler = async (req, res) => { + const targetName = String((req.params as Record).name ?? ''); + const current = this.configStore.getEffectiveConfig().addons; + const idx = current.findIndex((a) => a.name === targetName); + if (idx < 0) { + res.status(404).json({ error: 'not_found' }); + return; + } + const incoming = (req.body ?? {}) as Record; + // Lock the name on update — presets reference addons by name. + if (typeof incoming.name === 'string' && incoming.name !== targetName) { + res.status(400).json({ error: 'name_immutable' }); + return; + } + const result = validateAddon({ ...incoming, name: targetName }); + if (!result.ok) { + res.status(400).json({ error: 'invalid_addon', issues: result.issues }); + return; + } + const next = current.map((a, i) => (i === idx ? result.value : a)); + await this.persistAddons(next, req, `updated addon "${targetName}"`); + res.json({ ok: true, addon: result.value }); + }; + + handleDeleteAddon: RequestHandler = async (req, res) => { + const targetName = String((req.params as Record).name ?? ''); + if (PROTECTED_ADDON_NAMES.has(targetName)) { + res.status(400).json({ error: 'protected_addon' }); + return; + } + const cfg = this.configStore.getEffectiveConfig(); + if (!cfg.addons.some((a) => a.name === targetName)) { + res.status(404).json({ error: 'not_found' }); + return; + } + const blockingPresets = cfg.presets + .filter( + (p) => + p.addons.includes(targetName) || + (p.dependencies && p.dependencies.includes(targetName)), + ) + .map((p) => p.name); + if (blockingPresets.length > 0) { + res.status(409).json({ error: 'addon_in_use', presets: blockingPresets }); + return; + } + const next = cfg.addons.filter((a) => a.name !== targetName); + await this.persistAddons(next, req, `deleted addon "${targetName}"`); + res.json({ ok: true }); + }; + + private async persistAddons( + next: AddonsEntity[], + req: import('express').Request, + summary: string, + ): Promise { + const actor = (req as AuthedRequest).user; + await this.configStore.setOverride('addons', next, actor?.id ?? '', summary); + await this.reload(); + } + + handleListAudits: RequestHandler = async (_req, res) => { + const rows = await this.configStore.getAuditLog(50); + const userIds = Array.from(new Set(rows.map((r) => r.userId).filter(Boolean))); + const userMap = new Map(); + for (const id of userIds) { + const u = await this.auth.findUser(id); + if (u) userMap.set(id, { username: u.username, globalName: u.globalName }); + } + res.json( + rows.map((r) => ({ + id: r.id, + scope: r.scope, + userId: r.userId, + username: userMap.get(r.userId)?.globalName ?? userMap.get(r.userId)?.username ?? null, + at: r.at, + summary: r.summary, + })), + ); + }; + + handleListOverrides: RequestHandler = async (_req, res) => { + const rows = await this.configStore.listOverrides(); + const userIds = Array.from(new Set(rows.map((r) => r.updatedBy).filter(Boolean))); + const userMap = new Map(); + for (const id of userIds) { + const u = await this.auth.findUser(id); + if (u) userMap.set(id, { username: u.username, globalName: u.globalName }); + } + res.json( + rows.map((r) => ({ + scope: r.scope, + updatedBy: r.updatedBy, + updatedByName: + userMap.get(r.updatedBy)?.globalName ?? userMap.get(r.updatedBy)?.username ?? null, + updatedAt: r.updatedAt, + })), + ); + }; + + handleResetOverride: RequestHandler = async (req, res) => { + const scope = String((req.params as Record).scope ?? ''); + if (!(VALID_SCOPES as string[]).includes(scope)) { + res.status(400).json({ error: 'invalid_scope' }); + return; + } + const actor = (req as AuthedRequest).user; + await this.configStore.clearOverride(scope as ConfigScope, actor?.id ?? ''); + await this.reload(); + res.json({ ok: true }); + }; + + handleOpenPr: RequestHandler = async (req, res) => { + if (!this.octokit || !this.github) { + res.status(503).json({ error: 'pr_not_configured' }); + return; + } + const overrides = await this.configStore.listOverrides(); + if (overrides.length === 0) { + res.status(400).json({ error: 'no_overrides' }); + return; + } + const effective = this.configStore.getEffectiveConfig(); + const newContent = renderConfigJson(effective); + + const { owner, repo, branch: baseBranch } = this.github; + const actor = (req as AuthedRequest).user; + const ts = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, ''); + const head = `admin/config-sync-${ts}`; + + try { + // Read the file currently on the base branch — needed both for the + // "no diff" check and to pick up the file's git SHA for the update. + const existing = await this.octokit.rest.repos.getContent({ + owner, + repo, + ref: baseBranch, + path: 'config.json', + }); + const existingFile = existing.data as { + sha: string; + content?: string; + encoding?: string; + }; + const existingContent = + existingFile.encoding === 'base64' && existingFile.content + ? Buffer.from(existingFile.content, 'base64').toString('utf8') + : ''; + if (existingContent.trim() === newContent.trim()) { + res.status(400).json({ error: 'no_diff' }); + return; + } + + const baseRef = await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${baseBranch}`, + }); + await this.octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${head}`, + sha: baseRef.data.object.sha, + }); + + const scopeList = overrides.map((o) => o.scope).join(', '); + await this.octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + branch: head, + path: 'config.json', + message: `Admin sync: ${scopeList}`, + content: Buffer.from(newContent).toString('base64'), + sha: existingFile.sha, + }); + + const recent = await this.configStore.getAuditLog(20); + const body = [ + `This PR syncs the live admin overrides back to \`config.json\`.`, + ``, + `**Active overrides:** ${overrides.map((o) => `\`${o.scope}\``).join(', ')}`, + actor ? `**Opened by:** Discord \`${actor.id}\`` : '', + ``, + `### Recent audit entries`, + ...recent.map( + (r) => + `- \`${new Date(r.at).toISOString()}\` · ${r.scope} · ${r.userId} — ${r.summary}`, + ), + ] + .filter(Boolean) + .join('\n'); + + const pr = await this.octokit.rest.pulls.create({ + owner, + repo, + head, + base: baseBranch, + title: `Admin sync: ${scopeList}`, + body, + }); + + res.json({ ok: true, prUrl: pr.data.html_url }); + } catch (err) { + const e = err as { status?: number; message?: string }; + console.error('[admin] open PR failed:', e.status, e.message); + res.status(502).json({ error: 'github_error', detail: e.message ?? 'unknown' }); + } + }; +} + +function renderConfigJson(cfg: ConfigObject): string { + return JSON.stringify(cfg, null, 2) + '\n'; +} + +function validatePresets( + raw: unknown, + knownAddons: Set, +): { ok: true; value: PresetsEntity[] } | { ok: false; issues: string[] } { + const issues: string[] = []; + if (!Array.isArray(raw)) { + return { ok: false, issues: ['body must be an array of presets'] }; + } + if (raw.length > MAX_PRESETS) { + issues.push(`too many presets (max ${MAX_PRESETS})`); + } + + const seen = new Set(); + const value: PresetsEntity[] = []; + + raw.forEach((entry, i) => { + if (typeof entry !== 'object' || entry === null) { + issues.push(`preset[${i}] must be an object`); + return; + } + const e = entry as Record; + + const name = strField(e, 'name', i, issues, { required: true, max: MAX_NAME_LEN }); + const description = strField(e, 'description', i, issues, { max: MAX_TEXT_LEN }) ?? ''; + const subtext = strField(e, 'subtext', i, issues, { max: MAX_TEXT_LEN }) ?? ''; + const color = strField(e, 'color', i, issues, { required: true, max: 16 }); + const dependencyText = + 'dependencyText' in e + ? strField(e, 'dependencyText', i, issues, { max: MAX_TEXT_LEN }) + : undefined; + + if (color && !COLOR_RE.test(color)) { + issues.push(`preset[${i}].color must be a hex string like #ffdd57`); + } + if (name) { + if (seen.has(name)) { + issues.push(`preset[${i}].name "${name}" is duplicated`); + } else { + seen.add(name); + } + } + + const addons = stringArrayField(e, 'addons', i, issues, knownAddons, { + required: true, + max: MAX_ADDONS_PER_PRESET, + }); + const dependencies = + 'dependencies' in e && e.dependencies !== undefined + ? stringArrayField(e, 'dependencies', i, issues, knownAddons, { + max: MAX_ADDONS_PER_PRESET, + }) + : undefined; + + if (!name || !color || !addons) return; + + const out: PresetsEntity = { + name, + description, + subtext, + addons, + color, + }; + if (dependencies !== undefined) out.dependencies = dependencies; + if (dependencyText !== undefined) out.dependencyText = dependencyText; + value.push(out); + }); + + if (issues.length) return { ok: false, issues }; + return { ok: true, value }; +} + +function strField( + obj: Record, + key: string, + i: number, + issues: string[], + opts: { required?: boolean; max?: number }, +): string | undefined { + const v = obj[key]; + if (v === undefined || v === null || v === '') { + if (opts.required) issues.push(`preset[${i}].${key} is required`); + return undefined; + } + if (typeof v !== 'string') { + issues.push(`preset[${i}].${key} must be a string`); + return undefined; + } + if (opts.max !== undefined && v.length > opts.max) { + issues.push(`preset[${i}].${key} is too long (max ${opts.max})`); + return undefined; + } + return v; +} + +function stringArrayField( + obj: Record, + key: string, + i: number, + issues: string[], + knownAddons: Set, + opts: { required?: boolean; max?: number }, +): string[] | undefined { + const v = obj[key]; + if (v === undefined || v === null) { + if (opts.required) issues.push(`preset[${i}].${key} is required`); + return undefined; + } + if (!Array.isArray(v)) { + issues.push(`preset[${i}].${key} must be an array of addon names`); + return undefined; + } + if (opts.max !== undefined && v.length > opts.max) { + issues.push(`preset[${i}].${key} has too many entries (max ${opts.max})`); + return undefined; + } + const out: string[] = []; + for (const item of v) { + if (typeof item !== 'string') { + issues.push(`preset[${i}].${key} contains a non-string entry`); + continue; + } + if (!knownAddons.has(item)) { + issues.push(`preset[${i}].${key} references unknown addon "${item}"`); + continue; + } + out.push(item); + } + return out; +} + +function validateAddon( + raw: unknown, +): { ok: true; value: AddonsEntity } | { ok: false; issues: string[] } { + const issues: string[] = []; + if (typeof raw !== 'object' || raw === null) { + return { ok: false, issues: ['body must be an addon object'] }; + } + const e = raw as Record; + + const name = typeof e.name === 'string' ? e.name.trim() : ''; + if (!name) issues.push('name is required'); + else if (name.length > MAX_NAME_LEN) issues.push(`name is too long (max ${MAX_NAME_LEN})`); + else if (!ADDON_NAME_RE.test(name)) + issues.push('name may contain only letters, digits, spaces, _ and -'); + + const github = typeof e.github === 'string' ? e.github.trim() : ''; + if (!github) issues.push('github is required'); + else if (!GITHUB_REPO_RE.test(github)) issues.push('github must be in "owner/repo" format'); + + const ci = typeof e.ci === 'string' ? e.ci.trim() : ''; + if (!ci) issues.push('ci is required'); + else if (ci.length > 200) issues.push('ci is too long (max 200)'); + + const description = typeof e.description === 'string' ? e.description : ''; + if (description.length > MAX_DESCRIPTION_LEN) + issues.push(`description is too long (max ${MAX_DESCRIPTION_LEN})`); + + if (typeof e.gamemode !== 'boolean') issues.push('gamemode must be a boolean'); + + let versions: Record | undefined; + if (e.versions !== undefined && e.versions !== null) { + if (typeof e.versions !== 'object' || Array.isArray(e.versions)) { + issues.push('versions must be an object mapping MC version to addon version'); + } else { + const entries = Object.entries(e.versions as Record); + if (entries.length > MAX_VERSIONS_PER_ADDON) { + issues.push(`versions has too many entries (max ${MAX_VERSIONS_PER_ADDON})`); + } + const out: Record = {}; + for (const [k, v] of entries) { + if (!MC_VERSION_RE.test(k)) { + issues.push( + `versions key "${k}" must be made of letters, digits, dots, dashes or underscores`, + ); + continue; + } + if (typeof v !== 'string' || !v.trim()) { + issues.push(`versions["${k}"] must be a non-empty addon-version string`); + continue; + } + if (v.length > MAX_VERSION_VALUE_LEN) { + issues.push( + `versions["${k}"] is too long (max ${MAX_VERSION_VALUE_LEN})`, + ); + continue; + } + out[k] = v; + } + if (Object.keys(out).length > 0) versions = out; + } + } + + if (issues.length) return { ok: false, issues }; + + const value: AddonsEntity = { + name, + github: github as AddonsEntity['github'], + ci, + description, + gamemode: e.gamemode as boolean, + }; + if (versions) value.versions = versions; + return { ok: true, value }; +} diff --git a/src/api/api.ts b/src/api/api.ts index 07d58b8..3fb1072 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,10 +13,27 @@ 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'; +import { AdminGithubConfig, AdminManager } from './admin'; +import { AuthConfig, AuthManager } from './auth'; +import { ConfigStore } from './configStore'; +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[] = []; @@ -25,6 +42,26 @@ 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, + }; + + authSequelize = new Sequelize('auth', 'user', 'password', { + host: 'localhost', + dialect: 'sqlite', + logging: false, + storage: './../data/Auth.sqlite', + }); + auth: AuthManager = new AuthManager(this.loadAuthConfig(), this.authSequelize); + configStore: ConfigStore; + admin: AdminManager; + submissions: SubmissionManager = new SubmissionManager(this.auth, this.weblink, this.loadSubmissionsConfig()); + jarSequelize = new Sequelize('database', 'user', 'password', { host: 'localhost', dialect: 'sqlite', @@ -100,6 +137,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(); @@ -137,26 +175,124 @@ export default class ApiManager { this.octokit = new CustomOctokit({ auth: env && env.github_token ? env.github_token : '', }); + this.configStore = new ConfigStore(configConstructor, this.authSequelize); + this.admin = new AdminManager( + this.auth, + this.configStore, + () => this.reload(), + this.loadAdminGithubConfig(), + ); this.config = configConstructor; - const setAddons = async () => { - for await (const addon1 of configConstructor.addons) { - const versions = { - latest: (await this.jarCache.findOne({ where: { name: addon1.name } }))?.version || '0', - beta: (await this.jarCache.findOne({ where: { name: addon1.name } }))?.ciId?.toString() || '0', - ...addon1.versions, + // Once the override layer has loaded from disk, swap in the effective + // config and (re)build the derived addons array off it. + this.configStore.ready + .then(() => this.reload()) + .catch((err) => console.error('[api] initial configStore load failed:', err)); + + this.refreshBlueprints(); + } + + private async rebuildAddons(): Promise { + const next: AddonType[] = []; + for await (const addon1 of this.config.addons) { + const cached = await this.jarCache.findOne({ where: { name: addon1.name } }); + const versions = { + latest: cached?.version || '0', + beta: cached?.ciId?.toString() || '0', + ...addon1.versions, + }; + next.push({ + name: addon1.name, + description: addon1.description, + gamemode: addon1.gamemode, + github: addon1.github, + versions: versions, + }); + } + // Replace contents in-place so any captured references stay valid. + this.addons.length = 0; + this.addons.push(...next); + } + + async reload(): Promise { + this.config = this.configStore.getEffectiveConfig(); + await this.rebuildAddons(); + } + + 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); + } + + 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', }; - this.addons.push({ - name: addon1.name, - description: addon1.description, - gamemode: addon1.gamemode, - github: addon1.github, - versions: versions, - }); } - }; + } catch (e) {} + return null; + } + + private loadAdminGithubConfig(): AdminGithubConfig | null { + try { + const env = require('./../../env.json'); + const cfg = env?.admin_github; + if ( + cfg && + typeof cfg.token === 'string' && + cfg.token.trim() && + typeof cfg.owner === 'string' && + typeof cfg.repo === 'string' && + typeof cfg.branch === 'string' + ) { + return { + owner: cfg.owner, + repo: cfg.repo, + branch: cfg.branch, + token: cfg.token, + }; + } + } 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) { + const adminDiscordIds = Array.isArray(env.admin_discord_ids) + ? (env.admin_discord_ids as unknown[]).filter((x): x is string => typeof x === 'string') + : []; + return { + clientId: env.discord_client_id, + clientSecret: env.discord_client_secret, + redirectUri: env.discord_redirect_uri, + cookieSecure: inProd, + adminDiscordIds, + }; + } + } catch (e) {} + return null; + } - setAddons(); + 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 +447,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 +614,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/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..d722fd7 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,451 @@ +import { Request, RequestHandler, Response } from 'express'; +import * as crypto from 'crypto'; +import * as cron from 'node-cron'; +import { BOOLEAN, 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 + adminDiscordIds?: string[]; +} + +const SESSION_COOKIE = 'bb_session'; +const STATE_COOKIE = 'bb_oauth_state'; +const RETURN_COOKIE = 'bb_oauth_return'; +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const STATE_TTL_MS = 10 * 60 * 1000; +const MAX_SESSIONS_PER_USER = 5; +const DEFAULT_POST_LOGIN_PATH = '/submit'; + +/** Same-origin relative path. Rejects schemes, protocol-relative `//host`, + * traversal, and anything that isn't a sane in-app path. */ +function safeReturnPath(p: unknown): string | null { + if (typeof p !== 'string') return null; + if (p.length === 0 || p.length > 128) return null; + if (!/^\/[A-Za-z0-9_\-./~]*$/.test(p)) return null; + if (p.startsWith('//') || p.includes('..')) return null; + return p; +} + +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.bootstrap(sequelize).catch((err) => console.error('[auth] bootstrap failed:', err)); + + // Daily prune of expired sessions. + cron.schedule('0 4 * * *', () => this.pruneExpiredSessions()); + } + + private async bootstrap(sequelize: Sequelize): Promise { + await this.users.sync(); + // Older Auth.sqlite databases predate the isAdmin column. Add it + // idempotently — addColumn throws if it already exists, which we ignore. + try { + await sequelize.getQueryInterface().addColumn('users', 'isAdmin', { + type: BOOLEAN, + defaultValue: false, + allowNull: false, + }); + } catch (e) { + // Column already present. + } + await this.sessions.sync(); + await this.submissions.sync(); + + const adminIds = this.config?.adminDiscordIds ?? []; + for (const id of adminIds) { + await this.setAdmin(id, true); + } + } + + /** Idempotent. Granting creates a stub user row when the Discord ID has + * never logged in (so we can pre-grant). Revoking is a no-op when the + * user does not exist. */ + async setAdmin(discordId: string, isAdmin: boolean): Promise { + const existing = await this.users.findByPk(discordId); + if (existing) { + if (existing.isAdmin !== isAdmin) await existing.update({ isAdmin }); + return; + } + if (!isAdmin) return; + const now = Date.now(); + await this.users.create({ + id: discordId, + username: '', + globalName: null, + avatarHash: null, + createdAt: now, + lastLoginAt: 0, + acceptedTermsVersion: null, + isAdmin: true, + }); + } + + async listAdmins(): Promise< + Array<{ + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; + createdAt: number; + lastLoginAt: number; + }> + > { + const rows = await this.users.findAll({ where: { isAdmin: true } }); + return rows.map((u) => ({ + id: u.id, + username: u.username, + globalName: u.globalName, + avatarUrl: u.avatarHash + ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatarHash}.png?size=64` + : null, + createdAt: u.createdAt, + lastLoginAt: u.lastLoginAt, + })); + } + + 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 returnTo = safeReturnPath(req.query.return); + if (returnTo) { + res.cookie(RETURN_COOKIE, returnTo, { + httpOnly: true, + secure: this.config.cookieSecure, + sameSite: 'lax', + maxAge: STATE_TTL_MS, + path: '/', + }); + } else { + // Stale cookie from a prior aborted flow could otherwise win. + res.clearCookie(RETURN_COOKIE, { 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 cookies = (req as Request & { cookies?: Record }).cookies; + const cookieState = 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: '/' }); + const returnTo = safeReturnPath(cookies?.[RETURN_COOKIE]) ?? DEFAULT_POST_LOGIN_PATH; + res.clearCookie(RETURN_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, + isAdmin: false, + }); + } + + // 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(returnTo); + }; + + 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, + isAdmin: !!user.isAdmin, + }); + }; + + 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(); + }; + + requireAdmin: RequestHandler = async (req, res, next) => { + 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 || !user.isAdmin) { + res.status(403).json({ error: 'forbidden' }); + 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 new file mode 100644 index 0000000..8132b93 --- /dev/null +++ b/src/api/blueprintCatalog.ts @@ -0,0 +1,397 @@ +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() && 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[] = []; + 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 ?? detectImage(blueprintsDir, gameMode, name), + }); + } 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?: Record } | 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}` }); + } + } + // Inventories are common (chests/barrels of starter loot). Allow + // them, but substring-scan the YAML payloads for the same + // disallowed materials a hostile submitter might tuck inside an + // ItemStack rather than the blockData. + if (block.inventory && typeof block.inventory === 'object') { + for (const slotKey of Object.keys(block.inventory)) { + const yaml = block.inventory[slotKey]; + if (typeof yaml !== 'string') continue; + const lc = yaml.toLowerCase(); + for (const banned of Array.from(COMMAND_BLOCK_MATERIALS)) { + if (lc.includes(banned)) { + issues.push({ + field: `blocks.inventory[${slotKey}]`, + reason: `Disallowed material in stored ItemStack: ${banned}`, + }); + break; + } + } + } + } + } + } + return { ok: issues.length === 0, issues }; +} diff --git a/src/api/configStore.ts b/src/api/configStore.ts new file mode 100644 index 0000000..9600109 --- /dev/null +++ b/src/api/configStore.ts @@ -0,0 +1,104 @@ +import { Sequelize } from 'sequelize'; +import { AddonsEntity, ConfigObject, PresetsEntity } from '../config'; +import { + ConfigAuditFactory, + ConfigAuditModel, + ConfigAuditStatic, + ConfigOverrideFactory, + ConfigOverrideStatic, +} from './models/configStore'; + +export type ConfigScope = 'addons' | 'presets'; + +const SCOPES: ConfigScope[] = ['addons', 'presets']; + +export class ConfigStore { + private overrides = new Map(); + private overrideModel: ConfigOverrideStatic; + private auditModel: ConfigAuditStatic; + readonly ready: Promise; + + constructor(private readonly baseConfig: ConfigObject, sequelize: Sequelize) { + this.overrideModel = ConfigOverrideFactory(sequelize); + this.auditModel = ConfigAuditFactory(sequelize); + this.ready = this.init(); + } + + private async init(): Promise { + await this.overrideModel.sync(); + await this.auditModel.sync(); + const rows = await this.overrideModel.findAll(); + for (const row of rows) { + if (!isScope(row.scope)) continue; + try { + this.overrides.set(row.scope, JSON.parse(row.body)); + } catch (err) { + console.error(`[configStore] invalid JSON in override "${row.scope}":`, (err as Error).message); + } + } + } + + getEffectiveConfig(): ConfigObject { + return { + addons: (this.overrides.get('addons') as AddonsEntity[] | undefined) ?? this.baseConfig.addons, + presets: (this.overrides.get('presets') as PresetsEntity[] | undefined) ?? this.baseConfig.presets, + }; + } + + async setOverride( + scope: ConfigScope, + body: AddonsEntity[] | PresetsEntity[], + userId: string, + summary: string, + ): Promise { + const now = Date.now(); + await this.overrideModel.upsert({ + scope, + body: JSON.stringify(body), + updatedBy: userId, + updatedAt: now, + }); + this.overrides.set(scope, body); + await this.auditModel.create({ + scope, + userId, + at: now, + summary, + } as never); + } + + async clearOverride(scope: ConfigScope, userId: string): Promise { + await this.overrideModel.destroy({ where: { scope } }); + this.overrides.delete(scope); + await this.auditModel.create({ + scope, + userId, + at: Date.now(), + summary: `reset ${scope} to baseline`, + } as never); + } + + async getAuditLog(limit = 50): Promise { + return this.auditModel.findAll({ order: [['at', 'DESC']], limit }); + } + + async listOverrides(): Promise< + Array<{ scope: ConfigScope; updatedBy: string; updatedAt: number }> + > { + const rows = await this.overrideModel.findAll(); + const out: Array<{ scope: ConfigScope; updatedBy: string; updatedAt: number }> = []; + for (const r of rows) { + if (!isScope(r.scope)) continue; + out.push({ + scope: r.scope, + updatedBy: r.updatedBy, + updatedAt: r.updatedAt, + }); + } + return out; + } +} + +function isScope(s: string): s is ConfigScope { + return (SCOPES as string[]).includes(s); +} diff --git a/src/api/models/auth.ts b/src/api/models/auth.ts new file mode 100644 index 0000000..bb5c3c2 --- /dev/null +++ b/src/api/models/auth.ts @@ -0,0 +1,105 @@ +import { BuildOptions, Model, Sequelize, STRING, INTEGER, BOOLEAN } 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; + isAdmin: boolean; +} + +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, + isAdmin: { type: BOOLEAN, defaultValue: false, allowNull: false }, + }, + { 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/models/configStore.ts b/src/api/models/configStore.ts new file mode 100644 index 0000000..73fad97 --- /dev/null +++ b/src/api/models/configStore.ts @@ -0,0 +1,60 @@ +import { BuildOptions, Model, Sequelize, STRING, INTEGER, TEXT } from 'sequelize'; + +// ----- ConfigOverride: one row per scope (e.g. 'presets', 'addons'). ----- +// `body` is the JSON-stringified replacement for that scope of config.json. + +export interface ConfigOverrideAttributes { + scope: string; + body: string; + updatedBy: string; + updatedAt: number; +} + +export interface ConfigOverrideModel extends Model, ConfigOverrideAttributes {} + +export type ConfigOverrideStatic = typeof Model & { + new (values?: Record, options?: BuildOptions): ConfigOverrideModel; +}; + +export function ConfigOverrideFactory(sequelize: Sequelize): ConfigOverrideStatic { + return sequelize.define( + 'configOverrides', + { + scope: { type: STRING, primaryKey: true }, + body: TEXT, + updatedBy: STRING, + updatedAt: INTEGER, + }, + { timestamps: false }, + ); +} + +// ----- ConfigAudit: append-only log of admin config edits. ----- + +export interface ConfigAuditAttributes { + id: number; + scope: string; + userId: string; + at: number; + summary: string; +} + +export interface ConfigAuditModel extends Model, ConfigAuditAttributes {} + +export type ConfigAuditStatic = typeof Model & { + new (values?: Record, options?: BuildOptions): ConfigAuditModel; +}; + +export function ConfigAuditFactory(sequelize: Sequelize): ConfigAuditStatic { + return sequelize.define( + 'configAudits', + { + id: { type: INTEGER, primaryKey: true, autoIncrement: true }, + scope: STRING, + userId: STRING, + at: INTEGER, + summary: STRING, + }, + { timestamps: false, indexes: [{ fields: ['at'] }] }, + ); +} 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..6b49240 --- /dev/null +++ b/src/api/submissions.ts @@ -0,0 +1,415 @@ +import { RequestHandler } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Octokit } from 'octokit'; +import multer = require('multer'); +import Ajv2020 from 'ajv/dist/2020'; +import { 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 Ajv2020({ 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; + } + + // 4a. Gson serializes empty Map as `{}` rather than `[]`, + // even when complex-map-key serialization is enabled. The schema + // declares those fields as arrays, so coerce the empty-object form + // before validation. The PR keeps the user's original bytes. + const normalized = normalizeForValidation(parsed); + + // 5. Schema validation. + if (!this.validate(normalized)) { + const errors = (this.validate.errors ?? []).slice(0, 5).map((e) => { + const path = e.instancePath || '/'; + const params = e.params as Record | undefined; + if (e.keyword === 'additionalProperties' && params && 'additionalProperty' in params) { + return `${path}: unknown field "${params.additionalProperty}"`; + } + if (e.keyword === 'required' && params && 'missingProperty' in params) { + return `${path}: missing required field "${params.missingProperty}"`; + } + if (e.keyword === 'enum' && params && 'allowedValues' in params) { + return `${path}: must be one of ${(params.allowedValues as unknown[]).join(', ')}`; + } + return `${path}: ${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) { + const e = err as Error & { + status?: number; + response?: { data?: { message?: string; errors?: unknown } }; + }; + const detail = + e.response?.data?.message || + e.message || + 'unknown error'; + console.error('[submission] PR creation failed.', { + status: e.status, + message: e.message, + githubMessage: e.response?.data?.message, + githubErrors: e.response?.data?.errors, + }); + res.status(502).json({ + error: 'pr_failed', + reason: `Could not open pull request on weblink: ${detail}`, + }); + } + }; + + 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 normalizeForValidation(input: unknown): unknown { + if (typeof input !== 'object' || input === null) return input; + const out: Record = { ...(input as Record) }; + for (const key of ['blocks', 'attached', 'entities']) { + const v = out[key]; + if ( + v && + typeof v === 'object' && + !Array.isArray(v) && + Object.keys(v as Record).length === 0 + ) { + out[key] = []; + } + } + return out; +} + +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/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/index.ts b/src/index.ts index a55886f..2b8eccf 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,197 @@ 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()); +// In development, webpack's source-map devtool wraps each module in +// eval() — so a strict scriptSrc breaks the page. Allow 'unsafe-eval' +// outside production only. +const isProd = process.env.NODE_ENV === 'production'; +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], + imgSrc: ["'self'", 'https://cdn.discordapp.com', 'data:'], + connectSrc: ["'self'"], + scriptSrc: isProd ? ["'self'"] : ["'self'", "'unsafe-eval'"], + // 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) => + Promise.resolve() + .then(() => fn(req, res, next)) + .catch(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), +); + +// Admin routes — gated by requireAdmin (which checks requireSession internally). +// Mutations also require requireCsrf. +app.get( + '/api/admin/users', + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleListAdmins as (...a: unknown[]) => unknown), +); +app.post( + '/api/admin/users', + express.json(), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleSetAdmin as (...a: unknown[]) => unknown), +); +app.get( + '/api/admin/presets', + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleListPresets as (...a: unknown[]) => unknown), +); +app.put( + '/api/admin/presets', + express.json({ limit: '512kb' }), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleUpdatePresets as (...a: unknown[]) => unknown), +); +app.get( + '/api/admin/addons', + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleListAddons as (...a: unknown[]) => unknown), +); +app.post( + '/api/admin/addons', + express.json({ limit: '256kb' }), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleCreateAddon as (...a: unknown[]) => unknown), +); +app.put( + '/api/admin/addons/:name', + express.json({ limit: '256kb' }), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleUpdateAddon as (...a: unknown[]) => unknown), +); +app.delete( + '/api/admin/addons/:name', + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleDeleteAddon as (...a: unknown[]) => unknown), +); +app.get( + '/api/admin/audits', + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleListAudits as (...a: unknown[]) => unknown), +); +app.get( + '/api/admin/overrides', + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleListOverrides as (...a: unknown[]) => unknown), +); +app.delete( + '/api/admin/overrides/:scope', + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleResetOverride as (...a: unknown[]) => unknown), +); +app.post( + '/api/admin/pr', + express.json(), + wrap(apiManager.auth.requireCsrf as (...a: unknown[]) => unknown), + wrap(apiManager.auth.requireAdmin as (...a: unknown[]) => unknown), + wrap(apiManager.admin.handleOpenPr 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/scripts/prime-cache.ts b/src/scripts/prime-cache.ts new file mode 100644 index 0000000..f222ec9 --- /dev/null +++ b/src/scripts/prime-cache.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs'; +import apiClass from '../api/api'; +import { ConfigObject } from '../config'; + +/* Run as: `yarn build && yarn prime-cache` (or directly: + * cd dist && node scripts/prime-cache.js). + * This pulls the latest GitHub release + Jenkins build for every addon + * in config.json and writes them into JarCache.sqlite, bypassing the + * 6-minute cron drip-feed in api.ts. + * + * Without a real `github_token` in env.json this is rate-limited to ~60 + * requests/hour and will fail partway. Set a token first. + */ + +function envHasRealToken(): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const env = require('./../../env.json') as { github_token?: string }; + return !!env.github_token && !env.github_token.includes('XXXX'); + } catch { + return false; + } +} + +async function withConcurrency( + items: T[], + limit: number, + label: (item: T) => string, + worker: (item: T) => Promise, +): Promise { + const queue = items.slice(); + const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => { + while (queue.length) { + const next = queue.shift(); + if (!next) return; + const tag = label(next); + try { + await worker(next); + process.stdout.write(` ✓ ${tag}\n`); + } catch (err) { + process.stdout.write(` ✗ ${tag}: ${(err as Error).message}\n`); + } + } + }); + await Promise.all(runners); +} + +const config: ConfigObject = JSON.parse( + fs.readFileSync('./../config.json').toString(), +); + +const api = new apiClass(config); + +(async () => { + if (!envHasRealToken()) { + console.warn( + '\n[prime] WARNING: env.json has no real github_token (still the ghp_XXXX placeholder).', + ); + console.warn( + '[prime] Anonymous GitHub calls are capped at 60/hour — this run will likely fail partway.', + ); + console.warn( + '[prime] Generate a PAT at https://github.com/settings/tokens (no scopes needed for public repos),', + ); + console.warn('[prime] paste it into env.json under "github_token", then re-run.\n'); + } + + /* Give ApiManager a moment to open and sync JarCache before we start + * dropping rows in. */ + await new Promise((r) => setTimeout(r, 1500)); + + const addons = config.addons; + console.log( + `[prime] ${addons.length} addons — fetching latest GitHub releases (concurrency 6)…`, + ); + await withConcurrency( + addons, + 6, + (a) => a.name, + (a) => api.updateAsset(a), + ); + + console.log( + `[prime] fetching Jenkins CI builds (concurrency 4)…`, + ); + await withConcurrency( + addons, + 4, + (a) => `${a.name} (CI)`, + (a) => api.updateJenkins(a), + ); + + console.log('[prime] done. Cache populated.'); + process.exit(0); +})().catch((err) => { + console.error('[prime] fatal:', err); + process.exit(1); +}); diff --git a/src/web/ApiRequestManager.ts b/src/web/ApiRequestManager.ts index bba2fa2..c56251f 100644 --- a/src/web/ApiRequestManager.ts +++ b/src/web/ApiRequestManager.ts @@ -1,4 +1,4 @@ -import { AddonType, PresetsEntity, ThirdParty } from '../config'; +import { AddonsEntity, AddonType, BlueprintCatalog, PresetsEntity, ThirdParty } from '../config'; import axios from 'axios'; export async function GetPresets(): Promise { @@ -12,3 +12,200 @@ 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)}`; +} + +export interface SessionUser { + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; + acceptedTermsVersion: string | null; + csrfToken: string; + currentTermsVersion: string; + isAdmin: boolean; +} + +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 interface AdminUser { + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; + createdAt: number; + lastLoginAt: number; +} + +export async function GetAdminUsers(): Promise { + return (await axios.get('/api/admin/users')).data; +} + +export async function PostSetAdmin( + csrfToken: string, + discordId: string, + isAdmin: boolean, +): Promise { + await axios.post( + '/api/admin/users', + { discordId, isAdmin }, + { headers: { 'X-Csrf-Token': csrfToken } }, + ); +} + +export async function GetAdminPresets(): Promise { + return (await axios.get('/api/admin/presets')).data; +} + +export async function PutAdminPresets( + csrfToken: string, + presets: PresetsEntity[], +): Promise { + await axios.put('/api/admin/presets', presets, { + headers: { 'X-Csrf-Token': csrfToken }, + }); +} + +export async function GetAdminAddons(): Promise { + return (await axios.get('/api/admin/addons')).data; +} + +export async function PostAdminAddon( + csrfToken: string, + addon: AddonsEntity, +): Promise { + await axios.post('/api/admin/addons', addon, { + headers: { 'X-Csrf-Token': csrfToken }, + }); +} + +export async function PutAdminAddon( + csrfToken: string, + name: string, + addon: AddonsEntity, +): Promise { + await axios.put(`/api/admin/addons/${encodeURIComponent(name)}`, addon, { + headers: { 'X-Csrf-Token': csrfToken }, + }); +} + +export async function DeleteAdminAddon(csrfToken: string, name: string): Promise { + await axios.delete(`/api/admin/addons/${encodeURIComponent(name)}`, { + headers: { 'X-Csrf-Token': csrfToken }, + }); +} + +export type AdminScope = 'addons' | 'presets'; + +export interface AdminOverride { + scope: AdminScope; + updatedBy: string; + updatedByName: string | null; + updatedAt: number; +} + +export interface AdminAudit { + id: number; + scope: string; + userId: string; + username: string | null; + at: number; + summary: string; +} + +export async function GetAdminOverrides(): Promise { + return (await axios.get('/api/admin/overrides')).data; +} + +export async function GetAdminAudits(): Promise { + return (await axios.get('/api/admin/audits')).data; +} + +export async function DeleteAdminOverride( + csrfToken: string, + scope: AdminScope, +): Promise { + await axios.delete(`/api/admin/overrides/${encodeURIComponent(scope)}`, { + headers: { 'X-Csrf-Token': csrfToken }, + }); +} + +export async function PostAdminPr(csrfToken: string): Promise<{ ok: true; prUrl: string }> { + const r = await axios.post('/api/admin/pr', null, { + headers: { 'X-Csrf-Token': csrfToken }, + }); + return r.data; +} + +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..ad6ead9 --- /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 new file mode 100644 index 0000000..1a0eebc --- /dev/null +++ b/src/web/components/Blueprints.tsx @@ -0,0 +1,1038 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { setPendingBlueprint } from '../pendingBlueprint'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faDownload, + faSearch, + faArrowDown, + faTimes, + faLayerGroup, + faUpload, +} from '@fortawesome/free-solid-svg-icons'; +import { + BlueprintCatalog, + BlueprintCatalogGameMode, + BlueprintEntry, +} from '../../config'; +import { + BlueprintFileUrl, + BlueprintGameModeZipUrl, + BlueprintZipUrl, +} from '../ApiRequestManager'; +import VoxelStack, { variantFor } from './VoxelStack'; + +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 || '').replace(/^minecraft:/, '').replace(/_/g, ' '); +} + +/* "X minutes ago" — based on `generatedAt` (unix ms) from the catalog. */ +function timeAgo(ms: number): string { + const diff = Date.now() - ms; + if (diff < 60_000) return 'just now'; + const m = Math.round(diff / 60_000); + if (m < 60) return `${m} min ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h} h ago`; + const d = Math.round(h / 24); + return `${d} d ago`; +} + +function BlueprintCard({ + bp, + gameModeInfo, + selected, + onToggle, + expanded, + onHover, + tagColors, +}: { + bp: BlueprintEntry; + gameModeInfo?: BlueprintCatalogGameMode; + selected: boolean; + onToggle: () => void; + expanded: boolean; + onHover: (id: string | null) => void; + tagColors: Record; +}) { + const gameModeLabel = gameModeInfo?.displayName || bp.gameMode; + const dims = `${bp.stats.dimensions.x} × ${bp.stats.dimensions.y} × ${bp.stats.dimensions.z}`; + + return ( +
onHover(bp.id)} + onMouseLeave={() => onHover(null)} + style={{ + position: 'relative', + background: '#0c2038', + border: `1px solid ${selected ? 'var(--bp-accent)' : 'var(--bp-line)'}`, + borderRadius: 14, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + transition: 'transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease', + transform: expanded ? 'translateY(-2px)' : 'none', + boxShadow: expanded ? '0 18px 40px -16px rgba(0,0,0,0.6)' : 'none', + }} + > + {/* Image / placeholder area */} +
+
+ + {/* Top row: select + dimensions */} +
+ + {dims} +
+ + {bp.stats.sinking && ( +
+ + Sinking + +
+ )} + + {/* Image override if curator supplied one, else CSS-only voxel preview. */} +
+ {bp.image ? ( + {bp.displayName} + ) : ( + + )} +
+
+ + {/* Body */} +
+ +
+ {gameModeLabel} + {bp.author ? ( + <> + {' · '} + {bp.authorLink ? ( + e.stopPropagation()} + > + {bp.author} + + ) : ( + bp.author + )} + + ) : null} +
+ + {bp.description.length > 0 && ( +

+ {bp.description.join(' ')} +

+ )} + + {/* Quick-stat strip */} +
+ + + +
+ + {/* Hover-expanded detail */} + {expanded && ( +
+ {bp.stats.topBlocks.length > 0 && ( + <> + Top blocks +
+ {bp.stats.topBlocks.slice(0, 5).map((b) => ( + + {shortMaterial(b.material)} × {b.count} + + ))} +
+ + )} + {bp.stats.topEntities.length > 0 && ( + <> + Entities +
+ {bp.stats.topEntities.map((e) => ( + + {shortMaterial(e.type)} × {e.count} + + ))} +
+ + )} + {bp.stats.biomes.length > 0 && ( + <> + Biomes +
+ {bp.stats.biomes.map((b) => ( + + {shortMaterial(b)} + + ))} +
+ + )} +
+ )} + + {/* Tags + license */} +
+
+ {(bp.tags || []).sort().map((t) => ( + + {t} + + ))} +
+ {bp.license && ( + + {bp.license} + + )} +
+
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function UploadCard(): JSX.Element { + const [drag, setDrag] = useState(false); + const inputRef = useRef(null); + const history = useHistory(); + + const handFile = (file: File | null | undefined) => { + if (!file) return; + setPendingBlueprint(file); + history.push('/submit'); + }; + + return ( +
inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setDrag(true); + }} + onDragLeave={() => setDrag(false)} + onDrop={(e) => { + e.preventDefault(); + setDrag(false); + handFile(e.dataTransfer.files?.[0]); + }} + style={{ + position: 'relative', + borderRadius: 14, + background: drag ? 'rgba(127,179,204,0.10)' : 'rgba(10, 29, 51, 0.55)', + border: `1.5px dashed ${drag ? 'var(--bp-accent)' : 'var(--bp-line-strong)'}`, + padding: 22, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + minHeight: 380, + color: 'var(--bp-ink)', + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease', + }} + > + { + handFile(e.target.files?.[0]); + e.target.value = ''; + }} + /> +
+ +
+
+ Submit your blueprint +
+
+ Drop a{' '} + + .blueprint + {' '} + file here. We’ll parse it, you fill in the metadata, and we open a PR for + review. +
+ + + Choose file + +
+ max 5 MB · sign in with Discord +
+
+ ); +} + +function ToggleChip({ + active, + onClick, + swatch, + children, +}: { + active: boolean; + onClick: () => void; + swatch?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export default function BlueprintsPage({ data }: { data: BlueprintCatalog }) { + const [search, setSearch] = useState(''); + const [activeTags, setActiveTags] = useState>(new Set()); + const [gameModeFilter, setGameModeFilter] = useState('all'); + const [selected, setSelected] = useState>(new Set()); + const [hoverId, setHoverId] = useState(null); + + const tagColors = useMemo(() => { + const out: Record = {}; + for (const [name, tag] of Object.entries(data.tags || {})) out[name] = tag.color; + return out; + }, [data.tags]); + + const allTags = useMemo(() => { + const s = new Set(); + data.blueprints.forEach((b) => (b.tags || []).forEach((t) => s.add(t))); + return [...s].sort(); + }, [data.blueprints]); + + const allModes = useMemo(() => { + const s = new Set(); + data.blueprints.forEach((b) => s.add(b.gameMode)); + return [...s].sort(); + }, [data.blueprints]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return data.blueprints.filter((bp) => { + if (gameModeFilter !== 'all' && bp.gameMode !== gameModeFilter) return false; + if (activeTags.size && !(bp.tags || []).some((t) => activeTags.has(t))) return false; + if (!q) return true; + return ( + bp.displayName.toLowerCase().includes(q) || + bp.description.join(' ').toLowerCase().includes(q) || + bp.gameMode.toLowerCase().includes(q) || + (bp.author || '').toLowerCase().includes(q) || + (bp.tags || []).some((t) => t.toLowerCase().includes(q)) + ); + }); + }, [data.blueprints, gameModeFilter, activeTags, search]); + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleTag(t: string) { + setActiveTags((prev) => { + const next = new Set(prev); + if (next.has(t)) next.delete(t); + else next.add(t); + return next; + }); + } + + function clearFilters() { + setSearch(''); + setActiveTags(new Set()); + setGameModeFilter('all'); + } + + const filterBaseline = search === '' && activeTags.size === 0 && gameModeFilter === 'all'; + + return ( +
+ {/* ── Hero band ─────────────────────────────────────── */} +
+
+
+
+
+ + Sheet 01 · Island Blueprints +
+

+ Drop-in starter islands. +

+

+ Community-made{' '} + .blueprint{' '} + and bundle files for any BentoBox game mode. Curated from{' '} + + BentoBoxWorld/weblink + + . Want to add yours?{' '} + + Submit a blueprint + + . +

+
+
+ + {data.blueprints.length} blueprints · {allModes.length} game modes + + updated {timeAgo(data.generatedAt)} +
+
+
+
+ + {/* ── Filter rail + grid ─────────────────────────────── */} +
+
+ {/* Filter rail */} + + + {/* Card grid (UploadCard always sits first so users notice it) */} +
+ + {filtered.length === 0 ? ( +
+ No blueprints match those filters. +
+ ) : ( + filtered.map((bp) => ( + toggleSelect(bp.id)} + expanded={hoverId === bp.id} + onHover={setHoverId} + tagColors={tagColors} + /> + )) + )} +
+
+
+ + {/* ── Sticky selection bar ───────────────────────────── */} + {selected.size > 0 && ( +
+ + + {selected.size} + {' '} + selected + + + + Download ZIP + + +
+ )} +
+ ); +} diff --git a/src/web/components/ContentBox.tsx b/src/web/components/ContentBox.tsx index 39cd3fe..18d3d70 100644 --- a/src/web/components/ContentBox.tsx +++ b/src/web/components/ContentBox.tsx @@ -1,16 +1,49 @@ -import React, { Suspense } from 'react'; -import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; -import tw from 'twin.macro'; +import React, { Suspense, useEffect } from 'react'; +import { BrowserRouter as Router, Route, Switch, useLocation } from 'react-router-dom'; import Navigation from './Navigation'; -import { GetAddons, GetPresets, GetThirdParty } from '../ApiRequestManager'; +import Footer from './Footer'; +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'; -import { faQuestion } from '@fortawesome/free-solid-svg-icons'; -const PresetsPage = React.lazy(() => import('./PresetsPage')); +const Landing = React.lazy(() => import('./Landing')); 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')); +const AdminPage = React.lazy(() => import('./admin/AdminPage')); + +/** Sets `body[data-route="..."]` so design-tokens.css can disable + * the legacy `md:bg-background` cover image on the landing page. */ +function RouteAwareBody() { + const location = useLocation(); + useEffect(() => { + document.body.setAttribute('data-route', location.pathname); + }, [location.pathname]); + return null; +} + +/** Paper-card wrapper used for every interior route (everything except `/`). + * Retains the original "soft surface" feel without the yellow tint. */ +function InteriorWrap({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} export default function ContentBox() { function CustomElement() { @@ -27,14 +60,14 @@ export default function ContentBox() { ); } - function PresetsElement() { + function LandingElement() { const presets = () => GetPresets(); return ( }> {({ data, isLoading }) => { if (isLoading) return <>; - if (data) return a)} />; + if (data) return a)} />; }} @@ -55,44 +88,102 @@ export default function ContentBox() { ); } + function BlueprintsElement() { + const data = () => GetBlueprints(); + return ( + }> + + {({ data, isLoading }) => { + if (isLoading) return <>; + if (data) return ; + }} + + + ); + } + return ( -
+
+ - - - - - - - - - - - -
-

404

-

Page Not Found

-
-
-
+
+ + {/* Landing — full-bleed, no interior wrapper */} + + + + + {/* /presets used to be a standalone page; the + presets section now lives inside Landing under + #presets-anchor. The Presets nav item links there. */} + {/* /custom and /thirdparty render their own dark hero + band edge-to-edge — they manage their own surfaces. */} + + + + + + + {/* Blueprints renders its own navy sub-brand surface + edge-to-edge — skip the warm-paper InteriorWrap. */} + + + + {/* /submit lives in the navy-paper sub-brand and + renders its own hero band edge-to-edge. */} + + }> + + + + + + }> + + + + + + + }> + + + + + + + }> + + + + + + + }> + + + + + + +
+

+ 404 +

+

+ Page Not Found +

+
+
+
+
+
+
); } diff --git a/src/web/components/CustomPage.tsx b/src/web/components/CustomPage.tsx index 46dfba9..caea927 100644 --- a/src/web/components/CustomPage.tsx +++ b/src/web/components/CustomPage.tsx @@ -1,293 +1,972 @@ -import React, { useEffect, useState } from 'react'; -import tw from 'twin.macro'; +import React, { useEffect, useMemo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import { useForm } from 'react-hook-form'; +import { useLocation } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faDownload } from '@fortawesome/free-solid-svg-icons'; +import { + faDownload, + faSearch, + faLink, + faCheck, +} from '@fortawesome/free-solid-svg-icons'; import { AddonType } from '../../config'; -import { useLocation } from 'react-router'; -export default function CustomPage(props: { addonTypes: AddonType[] }) { - const { addonTypes } = props; - const [popupTitle, setPopupTitle] = useState('Start crafting your BentoBox!'); - const [versionTypes, setVersionTypes] = useState>({}); - const [versions, updateVersions] = useState([]); - const [popupText, setPopupText] = useState( - '**[BentoBox](https://bentobox.world/)** is a powerful plugin that transforms your Minecraft server into an extraordinary playground. BentoBox provides the platform for Gamemodes and Addons. By combining these components, you unlock a realm of endless possibilities and unparalleled customization. Craft your own BentoBox and embark on a thrilling journey filled with an array of captivating games and limitless fun. Immerse your players in a world where creativity knows no bounds and every adventure is unique. Unleash the full potential of your server with BentoBox and create an unforgettable experience that will leave your community in awe.', - ); - const [copied, setCopied] = useState('Copy URL To Setup'); - const { register, getValues, setValue, watch } = useForm(); +/* ── Number formatting ────────────────────────────────────────── */ +function fmtCount(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 10_000) return Math.round(n / 1000) + 'K'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return String(n); +} - const value = watch('version'); +/* ── Version state classification ────────────────────────────── */ +type VersionState = 'latest' | 'beta' | 'older'; +function versionState(v: string | undefined): VersionState { + if (v === 'latest') return 'latest'; + if (v === 'beta') return 'beta'; + return 'older'; +} - function nFormatter(num: number, digits: number): string { - const lookup = [ - { value: 1, symbol: '' }, - { value: 1e3, symbol: 'k' }, - { value: 1e6, symbol: 'M' }, - { value: 1e9, symbol: 'G' }, - { value: 1e12, symbol: 'T' }, - { value: 1e15, symbol: 'P' }, - { value: 1e18, symbol: 'E' }, - ]; - const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; - const item = lookup - .slice() - .reverse() - .find(function (item) { - return num >= item.value; - }); - return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0'; +function VersionWarning({ state }: { state: VersionState }): JSX.Element { + const map: Record< + VersionState, + { bg: string; fg: string; label: string } + > = { + latest: { + bg: 'var(--bb-green-soft)', + fg: 'var(--bb-green-ink)', + label: 'LATEST · ACTIVELY SUPPORTED', + }, + beta: { + bg: 'oklch(0.95 0.06 80)', + fg: 'var(--bb-amber-ink)', + label: 'BETA · TEST BUILDS ONLY', + }, + older: { + bg: 'oklch(0.95 0.05 25)', + fg: 'var(--bb-rose)', + label: 'OLDER · LIMITED SUPPORT', + }, + }; + const s = map[state]; + return ( + + + {s.label} + + ); +} + +/* ── Stable colour for the addon row colour-stripe ───────────── */ +const GAMEMODE_HEX: Record = { + bskyblock: '#ffdd57', + aoneblock: '#48c774', + acidisland: '#48c774', + caveblock: '#a07a4f', + boxed: '#9aa3b3', + poseidon: '#3a8cb6', + skygrid: '#a8d6ee', + raftmode: '#3a8cb6', +}; +function accentForAddon(a: AddonType): string { + if (a.gamemode) { + const k = a.name.toLowerCase(); + return GAMEMODE_HEX[k] || '#48c774'; } + return 'var(--bb-mute-2)'; +} + +interface AddonRowProps { + addon: AddonType; + enabled: boolean; + checked: boolean; + versionLabel: string; + versionTone: VersionState | 'none'; + inputProps: React.InputHTMLAttributes; + isHovered: boolean; + onHover: () => void; +} - class Generator extends React.Component { - render() { +function AddonRow({ + addon, + enabled, + checked, + versionLabel, + versionTone, + inputProps, + isHovered, + onHover, +}: AddonRowProps): JSX.Element { + const versionPill = (() => { + if (versionTone === 'none') { return ( - + — + ); } - } + const tone = + versionTone === 'latest' + ? { bg: 'var(--bb-green-soft)', fg: 'var(--bb-green-ink)' } + : versionTone === 'beta' + ? { bg: 'oklch(0.95 0.06 80)', fg: 'var(--bb-amber-ink)' } + : { bg: 'rgba(207,27,33,0.10)', fg: 'var(--bb-rose)' }; + return ( + + {versionLabel} + + ); + })(); - function addons(version: string) { - return getAddonElements(false, addonTypes, version); - } + return ( + + ); +} - function gamemodes(version: string) { - return getAddonElements(true, addonTypes, version); - } +/* ── The page ────────────────────────────────────────────────── */ +export default function CustomPage(props: { addonTypes: AddonType[] }) { + const { addonTypes } = props; + const { register, getValues, setValue, watch } = useForm(); + const value = (watch('version') as string) || 'latest'; + const watchedAll = watch(); + const [versions, setVersionList] = useState([]); + const [hovered, setHovered] = useState(null); + const [search, setSearch] = useState(''); + const [groupBy, setGroupBy] = useState<'category' | 'flat'>('category'); + const [copied, setCopied] = useState('Copy share URL'); const queryVersion = useQuery().get('v'); - function setVersions() { - const oldVersions = versionTypes; - for (const addonsKey in addonTypes) { - const versions = Object.keys(addonTypes[addonsKey].versions); - if (!versions || Object.keys(versions).length < 1) return; - oldVersions[addonsKey] = versions; - } - setVersionTypes(oldVersions); - const versionValues = Object.values(versionTypes).flat(1); + /* Total downloads — point of pride. */ + const lifetimeDownloads = useMemo( + () => + addonTypes + .filter((a) => a.name.toLowerCase() !== 'bentobox') + .reduce((s, a) => s + (a.downloads || 0), 0), + [addonTypes], + ); - updateVersions( - versionValues.filter(function (item, pos) { - return item != 'latest' && item != 'beta' && versionValues.indexOf(item) == pos; - }), + const selectedAddonNames = useMemo(() => { + return Object.keys(watchedAll).filter( + (k) => k !== 'version' && watchedAll[k], ); - } + }, [watchedAll]); - function getAddonElements(isGamemode: boolean, addons: AddonType[], inVersion: string): JSX.Element[] { - return addons - .filter((a) => a.gamemode === isGamemode) - .filter((a) => a.name.toLowerCase() != 'bentobox') - .map((addon) => { - let version = ''; - let color = '#efd112'; - if (inVersion === 'beta') { - version = 'b-' + addon.versions[inVersion]; - color = '#f72811'; - } else { - if (addon.versions[inVersion]) version = 'v' + addon.versions[inVersion]; - } - if (inVersion === 'latest') color = '#3b82f6'; - const enabled = Object.keys(addon.versions).includes(value); - return ( -
- -
- ); - }); - } + const selectedDownloadable = useMemo( + () => + selectedAddonNames.filter((name) => + Object.keys( + addonTypes.find((a) => a.name === name)?.versions || {}, + ).includes(value), + ), + [selectedAddonNames, addonTypes, value], + ); - function useQuery() { - return new URLSearchParams(useLocation().search); - } + const selectedDownloads = useMemo( + () => + addonTypes + .filter((a) => selectedAddonNames.includes(a.name)) + .reduce((s, a) => s + (a.downloads || 0), 0), + [addonTypes, selectedAddonNames], + ); + /* Available versions list */ + useEffect(() => { + const seen: Record = {}; + for (const a of addonTypes) { + for (const v of Object.keys(a.versions)) { + if (v !== 'latest' && v !== 'beta') seen[v] = true; + } + } + setVersionList(Object.keys(seen)); + }, [addonTypes]); + + /* Hash → preselect addons (preserve original behaviour) */ useEffect(() => { - setVersions(); const hash = location.hash; if (hash.length < 2) return; - let addonNames: string[]; try { - addonNames = JSON.parse(decodeURI(hash.slice(1, hash.length))); + const names: string[] = JSON.parse(decodeURI(hash.slice(1))); + names.forEach((n) => setValue(n, true)); } catch (e) { console.error(e); - return; } - addonNames.forEach((addonName) => { - setValue(addonName, true); - }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + /* ?v= preselects MC version once the list is built */ useEffect(() => { - if (queryVersion && versions.includes(queryVersion)) setValue('version', queryVersion); - }, [versions]); + if (queryVersion && versions.includes(queryVersion)) + setValue('version', queryVersion); + }, [versions, queryVersion, setValue]); + + /* Filtered + grouped addon list (excludes the bentobox row) */ + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return addonTypes + .filter((a) => a.name.toLowerCase() !== 'bentobox') + .filter((a) => !q || a.name.toLowerCase().includes(q)); + }, [addonTypes, search]); + + const groups = useMemo(() => { + if (groupBy === 'category') { + const out: Record<'gamemode' | 'addon', AddonType[]> = { + gamemode: [], + addon: [], + }; + for (const a of filtered) (a.gamemode ? out.gamemode : out.addon).push(a); + return out; + } + return { all: filtered } as Record; + }, [filtered, groupBy]); + + function generate() { + if (selectedDownloadable.length < 1) return; + open( + `/api/generate?downloads=${encodeURI( + '[' + selectedDownloadable.map((a) => '"' + a + '"').join(',') + ']', + )}&version=${value}`, + ); + } + + function copyUrl() { + navigator.clipboard.writeText( + `${location.protocol}//${location.host}/custom${ + value === 'latest' ? '' : `?v=${value}` + }${ + selectedDownloadable.length === 0 + ? '' + : '#' + + encodeURI( + '[' + + selectedDownloadable + .map((a) => '"' + a + '"') + .join(',') + + ']', + ) + }`, + ); + setCopied('Copied!'); + setTimeout(() => setCopied('Copy share URL'), 2500); + } + + const bentoBox = addonTypes.find((a) => a.name.toLowerCase() === 'bentobox'); + const apiPreviewArr = + '[' + selectedDownloadable.map((s) => `"${s}"`).join(',') + ']'; + + const groupOrder: Array = + groupBy === 'category' ? ['gamemode', 'addon'] : ['all']; + const groupLabels: Record = { + gamemode: 'Game modes', + addon: 'Addons', + all: 'All addons', + }; return ( -
-
+ {/* ── HERO ────────────────────────────────────────── */} +
- -
- Minecraft Version:  - -
- BentoBox Version:  - - {value === 'beta' && 'b-'} - {addonTypes.filter((a) => a.name.toLowerCase() === 'bentobox')[0].versions[value]} - -
- {value === 'latest' - ? 'These Versions are for the Latest Version of Minecraft Only' - : value === 'beta' - ? 'Beta Versions Are Not Necessarily Stable. Use With Caution' - : 'These Versions May Be Old and Unsupported! Proceed With Caution'} -
-
-

Select Gamemodes

- Gamemodes bring in original games to your server. -
{gamemodes(value)}
-
-
-

Select Addons

- - {"Addons enhance the player's experience and make your server unique by adding new features."} - -
- {addons(value)} -
-
- - -
-
-
+ Custom build +
+

+ Build your bundle +

+

- {popupTitle} + Pick a Minecraft version, then check the addons you want. Hover any + row to read what it does. Copy a share URL when you’re happy. +

+
+ + Lifetime + + + {lifetimeDownloads.toLocaleString()} + + + downloads across all addons + +
+
+ + + {/* ── PAPER SECTION ───────────────────────────────── */} +
+
+ {/* ── LEFT: addon picker ─────────────────── */} +
+ {/* Toolbar */} +
+
+ + + + setSearch(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px 8px 34px', + border: '1px solid var(--bb-line-strong)', + borderRadius: 8, + background: 'var(--bb-paper-2)', + fontSize: 13, + fontFamily: 'inherit', + color: 'var(--bb-ink)', + outline: 'none', + }} + /> +
+
+ {( + [ + ['category', 'Category'], + ['flat', 'Flat'], + ] as Array<['category' | 'flat', string]> + ).map(([k, l]) => ( + + ))} +
+
+ + {/* Grouped list */} +
+ {groupOrder.map((group) => { + const items = (groups as Record)[group]; + if (!items || !items.length) return null; + return ( +
+ {groupBy === 'category' && ( +
+ + {groupLabels[group]} + + + {items.length} addons + +
+ )} + {items.map((a) => { + const enabled = Object.keys(a.versions).includes( + value, + ); + const checked = !!watchedAll[a.name]; + const rawVersion = a.versions[value]; + let versionTone: VersionState | 'none' = 'none'; + let label = '—'; + if (rawVersion) { + if (value === 'beta') { + versionTone = 'beta'; + label = 'b-' + rawVersion; + } else if (value === 'latest') { + versionTone = 'latest'; + label = 'v' + rawVersion; + } else { + versionTone = 'older'; + label = 'v' + rawVersion; + } + } + return ( + setHovered(a)} + inputProps={{ + ...register(a.name), + }} + /> + ); + })} +
+ ); + })} + {filtered.length === 0 && ( +
+ No addons match — try clearing the filter. +
+ )} +
+ + {/* ── RIGHT: sticky info / actions ───────── */}
- - {popupText.replace('\n', '\n ')} - + {/* Version + warning */} +
+
+ Minecraft version +
+
+ + +
+
+ BentoBox core{' '} + + {value === 'beta' && 'b-'} + {bentoBox?.versions[value] || '—'} + +
+
+ + {/* Description preview */} +
+
+ Description +
+ {hovered ? ( + <> +
+ + + {hovered.name} + + + {hovered.gamemode ? 'gamemode' : 'addon'} + +
+
+ + {' '} + + {(hovered.downloads || 0).toLocaleString()} + {' '} + downloads + + + Latest{' '} + + {hovered.versions.latest + ? 'v' + hovered.versions.latest + : '—'} + + +
+
+ + {hovered.description} + +
+ + ) : ( +

+ Hover an addon to see what it does. +

+ )} +
+ + {/* Your bundle */} +
+
+ Your bundle + + {selectedAddonNames.length} addons ·{' '} + + {selectedDownloads.toLocaleString()} + {' '} + total dl + +
+
+ {selectedAddonNames.length === 0 && ( + + Pick at least one addon to enable downloads. + + )} + {selectedAddonNames.map((name) => { + const a = addonTypes.find((x) => x.name === name); + if (!a) return null; + return ( + + + {name} + + ); + })} +
+
+ + +
+
+ /api/generate?downloads={apiPreviewArr}&version={value} +
+
-
+
); } + +function useQuery() { + return new URLSearchParams(useLocation().search); +} diff --git a/src/web/components/Footer.tsx b/src/web/components/Footer.tsx new file mode 100644 index 0000000..6d185e3 --- /dev/null +++ b/src/web/components/Footer.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCube, + faWrench, + faPuzzlePiece, + faLayerGroup, + faPaperPlane, + faHeart, + faQuestion, +} from '@fortawesome/free-solid-svg-icons'; +import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +interface ColumnLink { + label: string; + to?: string; + href?: string; + icon?: IconDefinition; +} + +const COLUMNS: { title: string; links: ColumnLink[] }[] = [ + { + title: 'Downloads', + links: [ + { label: 'Presets', to: '/#presets-anchor', icon: faCube }, + { label: 'Custom build', to: '/custom', icon: faWrench }, + { label: 'Third-party addons', to: '/thirdparty', icon: faPuzzlePiece }, + { label: 'Blueprints', to: '/blueprints', icon: faLayerGroup }, + ], + }, + { + title: 'Project', + links: [ + { label: 'Documentation', href: 'https://docs.bentobox.world/', icon: faQuestion }, + { label: 'GitHub', href: 'https://github.com/BentoBoxWorld' }, + { label: 'Submit a blueprint', to: '/submit', icon: faPaperPlane }, + { label: 'Discord community', href: 'https://discord.gg/KwjFBUaNSt' }, + ], + }, + { + title: 'Legal', + links: [ + { label: 'Terms of Service', to: '/terms' }, + { label: 'Privacy Policy', to: '/privacy' }, + ], + }, +]; + +export default function Footer() { + return ( +
+
+
+ {/* Brand block */} +
+
+
+ B +
+
+ BentoBox +
+
+

+ Open-source island game-mode platform for Paper Minecraft servers. + Built by volunteers, free forever. +

+ + + Sponsor on GitHub + +
+ + {/* Link columns */} + {COLUMNS.map((col) => ( +
+
+ {col.title} +
+
    + {col.links.map((l) => { + const inner = ( + + {l.icon && ( + + )} + {l.label} + + ); + return ( +
  • + {l.to ? ( + + {inner} + + ) : ( + + {inner} + + )} +
  • + ); + })} +
+
+ ))} +
+ +
+
+ © BentoBoxWorld · Site by{' '} + + tastybento + {' '} + · Not affiliated with Mojang or Microsoft +
+ +
+
+ + +
+ ); +} diff --git a/src/web/components/Landing.tsx b/src/web/components/Landing.tsx new file mode 100644 index 0000000..141a137 --- /dev/null +++ b/src/web/components/Landing.tsx @@ -0,0 +1,577 @@ +import React, { useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCube, + faWrench, + faPuzzlePiece, + faLayerGroup, + faCheck, + faArrowRight, + faHeart, +} from '@fortawesome/free-solid-svg-icons'; +import PresetsPage from './PresetsPage'; +import { PresetsEntity } from '../../config'; + +interface LandingProps { + presets: PresetsEntity[]; +} + +function ReasonBullet({ children }: { children: React.ReactNode }) { + return ( +
  • + + + + + {children} + +
  • + ); +} + +function StatCell({ + label, + value, + sub, + last, +}: { + label: string; + value: string; + sub?: string; + last?: boolean; +}) { + return ( +
    +
    + {label} +
    +
    + {value} +
    + {sub && ( +
    {sub}
    + )} +
    + ); +} + +export default function Landing({ presets }: LandingProps) { + const { hash } = useLocation(); + + function scrollToPresets(e: React.MouseEvent) { + e.preventDefault(); + const el = document.getElementById('presets-anchor'); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY - 60; + window.scrollTo({ top, behavior: 'smooth' }); + } + } + + /* When the user navigates here from another route via /#presets-anchor, + * scroll on mount/hash-change. The 60px offset clears the sticky header. */ + useEffect(() => { + if (!hash) return; + const id = hash.startsWith('#') ? hash.slice(1) : hash; + const tryScroll = () => { + const el = document.getElementById(id); + if (!el) return false; + const top = el.getBoundingClientRect().top + window.scrollY - 60; + window.scrollTo({ top, behavior: 'smooth' }); + return true; + }; + if (tryScroll()) return; + const t = setTimeout(tryScroll, 80); + return () => clearTimeout(t); + }, [hash]); + + return ( +
    + {/* ── HERO ───────────────────────────────────────────────── */} +
    +
    +
    + {/* Left: copy */} +
    +
    + + Powering 1,100+ servers worldwide +
    +

    + Island game modes, +
    + snap-together simple. +

    +

    + BentoBox powers island-style game modes for Paper servers. Pick the + modes you want, drop them in, and you’re running. No forks, no + outdated code — one platform that stays current with every + Minecraft release. +

    +
    + + + Browse presets + + + + Build your own + +
    +
    + Compatible with + {['26.1', '1.21', '1.20', '1.19', '1.18', '1.17'].map((v) => ( + + {v} + + ))} +
    +
    + + {/* Right: quickstart card */} +
    +
    + Quickstart +
    +
    + Running in 3 steps +
    +
      + {[ + { + t: 'Drop in your Paper plugins folder', + s: 'BentoBox + the addons you picked, all in one ZIP.', + }, + { + t: 'Restart your server', + s: 'Game modes auto-register and create their worlds.', + }, + { + t: 'Type /', + s: 'e.g. /bsb to start your first island. That’s it.', + }, + ].map((s, i) => ( +
    1. +
      + {i + 1} +
      +
      +
      + {s.t} +
      +
      + {s.s} +
      +
      +
    2. + ))} +
    +
    +
    +
    + + {/* Stat ticker */} +
    +
    +
    + + + + +
    +
    +
    +
    + + {/* ── ON-PAPER: Why BentoBox + Sponsor ─────────────────── */} +
    +
    +
    +
    +
    Why server admins choose BentoBox
    +

    + Low-risk. Battle-tested. Open source. +

    +
      + + Run multiple game modes on one server with shared features + — challenges, warps, levels, leaderboards. + + + 20+ addons let you customize exactly the experience you want. + + + Actively maintained and always up to date with the latest + Minecraft version. + + + Free and open source — used on 1,100+ servers worldwide. + + + Rich API for developers who want to build custom addons. + +
    +
    + + + Third-party addons + + + + Island blueprints + + + + Documentation + +
    +
    + + +
    +
    +
    + + {/* ── Embedded presets ─────────────────────────────────── */} +
    +
    +
    +
    Start here
    +

    + Pick a preset +

    +

    + Each preset bundles BentoBox plus a curated set of addons for one game + mode. Download the ZIP, drop it in plugins/, and restart. +

    +
    + +
    + + + Or build your own custom mix + + +
    +
    +
    +
    + ); +} diff --git a/src/web/components/Navigation.tsx b/src/web/components/Navigation.tsx index afa76cc..aef214a 100644 --- a/src/web/components/Navigation.tsx +++ b/src/web/components/Navigation.tsx @@ -1,72 +1,576 @@ +import React, { useEffect, useRef, useState } from 'react'; 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 React, { useEffect } from 'react'; -import { faDiscord } from '@fortawesome/free-brands-svg-icons'; +import { + faCube, + faWrench, + faPuzzlePiece, + faLayerGroup, + faHome, + faBars, + faTimes, + faShieldAlt, + faCaretDown, + faSignOutAlt, + faUser, +} from '@fortawesome/free-solid-svg-icons'; +import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { GetMe, PostLogout, SessionUser } from '../ApiRequestManager'; + +interface Tab { + to: string; + label: string; + icon: IconDefinition; + exact?: boolean; + /** When set, the entry renders as a hash anchor on the landing page, + * not a router link — Presets lives inside Landing. */ + landingAnchor?: string; +} + +const NAV_OFFSET_PX = 60; + +function smoothScrollToAnchor(anchorId: string) { + const el = document.getElementById(anchorId); + if (!el) return; + const top = el.getBoundingClientRect().top + window.scrollY - NAV_OFFSET_PX; + window.scrollTo({ top, behavior: 'smooth' }); +} + +/** When already on `/`, prevent the navigation and just scroll. Otherwise let + * the browser navigate to `/#anchor` and Landing's mount-effect handles it. */ +function handleAnchorClick( + e: React.MouseEvent, + anchorId: string, + pathname: string, +) { + if (pathname === '/') { + e.preventDefault(); + smoothScrollToAnchor(anchorId); + } +} + +const BASE_TABS: Tab[] = [ + { to: '/', label: 'Home', icon: faHome, exact: true }, + { to: '/', label: 'Presets', icon: faCube, landingAnchor: 'presets-anchor' }, + { to: '/custom', label: 'Custom', icon: faWrench }, + { to: '/thirdparty', label: 'Third-Party', icon: faPuzzlePiece }, + { to: '/blueprints', label: 'Blueprints', icon: faLayerGroup }, +]; + +const ADMIN_TAB: Tab = { to: '/admin', label: 'Admin', icon: faShieldAlt }; export default function Navigation() { const location = useLocation(); + const [scrolled, setScrolled] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const [user, setUser] = useState(undefined); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + let cancelled = false; + GetMe() + .then((u) => { + if (!cancelled) setUser(u); + }) + .catch(() => { + if (!cancelled) setUser(null); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!menuOpen) return; + const onDocClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [menuOpen]); + + async function handleLogout() { + if (!user) return; + try { + await PostLogout(user.csrfToken); + } finally { + // Hard reload so every component (admin link, account page, etc.) + // re-fetches /api/me and re-renders without a session. + window.location.href = '/'; + } + } + + const isAdmin = !!user && user.isAdmin; + const TABS: Tab[] = isAdmin ? [...BASE_TABS, ADMIN_TAB] : BASE_TABS; + + // The header sits on top of the dark hero on `/` and stays transparent + // until the user scrolls past it. On every other route it's solid paper. + const isLanding = location.pathname === '/'; - useEffect(() => {}, [location]); + useEffect(() => { + if (!isLanding) { + setScrolled(true); + return; + } + setScrolled(false); + const onScroll = () => setScrolled(window.scrollY > 80); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [isLanding]); + + useEffect(() => { + // Close mobile drawer on route change + setMobileOpen(false); + }, [location.pathname]); + + const transparent = isLanding && !scrolled && !mobileOpen; + + const headerStyle: React.CSSProperties = { + position: 'sticky', + top: 0, + zIndex: 50, + background: transparent ? 'transparent' : 'rgba(245, 241, 232, 0.92)', + backdropFilter: transparent ? 'none' : 'blur(10px)', + WebkitBackdropFilter: transparent ? 'none' : 'blur(10px)', + borderBottom: transparent ? '1px solid transparent' : '1px solid var(--bb-line)', + transition: 'background 0.2s ease, border-color 0.2s ease', + color: transparent ? '#fff' : 'var(--bb-ink)', + }; return ( -
    -
      -
    • +
      +
      + {/* Logo */} +
      - - -  Presets - + B
      -
    • -
    • -
      - - -  Custom - +
      + + BentoBox + + + Downloads +
      -
    • -
    • -
      + + {/* Desktop tabs */} + + + {/* Right cluster (desktop) */} + -
    • -
    • + + GitHub + + + + Discord + + {user && ( +
      + + {menuOpen && ( +
      + setMenuOpen(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 10px', + borderRadius: 6, + textDecoration: 'none', + color: 'inherit', + fontSize: 14, + }} + > + + Account + + {isAdmin && ( + setMenuOpen(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 10px', + borderRadius: 6, + textDecoration: 'none', + color: 'inherit', + fontSize: 14, + }} + > + + Admin + + )} + +
      + )} +
      + )} +
    + + {/* Mobile hamburger */} + + + + {/* Mobile drawer */} + {mobileOpen && ( +
    + - - + {user && ( +
    + {user.avatarUrl ? ( + + ) : ( + + )} + + {user.globalName || user.username} + + +
    + )} +
    + )} + + {/* Mobile-only display rule for hamburger (scoped via element) */} + ); } diff --git a/src/web/components/PremadeCard.tsx b/src/web/components/PremadeCard.tsx index 0745753..65003a2 100644 --- a/src/web/components/PremadeCard.tsx +++ b/src/web/components/PremadeCard.tsx @@ -1,71 +1,162 @@ import React from 'react'; import tw from 'twin.macro'; import ReactMarkdown from 'react-markdown'; -import { PremadeCardType } from '../web'; import remarkBreaks from 'remark-breaks'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDownload, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { PremadeCardType } from '../web'; export default function PremadeCard(props: PremadeCardType) { const { addons, color, description, name, subtext, dependencies, dependencyText } = props; - if (!addons) return <>; - const addonsList = addons.map((addon) => { - return
  • {addon}
  • ; - }); - - let pluginsList: JSX.Element[] | null = null; - - if (dependencies) { - pluginsList = dependencies.map((addon) => { - return
  • {addon}
  • ; - }); - } + const downloadHref = `/api/generate?downloads=${encodeURI( + '[' + addons.map((a) => '"' + a + '"').join(',') + ']', + )}`; + const customizeHref = `/custom#${encodeURI( + '[' + addons.map((a) => '"' + a + '"').join(',') + ']', + )}`; return ( -
    -

    {name}

    -
    - {description} -
    -
    - {subtext} -
    -
      {addonsList}
    - {pluginsList && dependencyText && ( - <> -
    - {dependencyText} + {/* Color accent stripe */} +
    + +
    +
    +

    + {name} +

    +
    + +
    + {description} +
    + + {subtext && ( +
    + {subtext}
    -
      - {pluginsList} -
    - - )} -
    -
    + )} + +
      + {addons.map((addon) => ( +
    • + + {addon} +
    • + ))} +
    + + {dependencies && dependencyText && ( + <> +
    + + {dependencyText} + +
    +
      + {dependencies.map((dep) => ( +
    • + {dep} +
    • + ))} +
    + + )} + + -
    -
    + ); } diff --git a/src/web/components/PresetsPage.tsx b/src/web/components/PresetsPage.tsx index feb3a33..788511e 100644 --- a/src/web/components/PresetsPage.tsx +++ b/src/web/components/PresetsPage.tsx @@ -1,28 +1,69 @@ import React from 'react'; -import PremadeCard from './PremadeCard'; import tw from 'twin.macro'; +import { NavLink } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCubes, faWrench, faPuzzlePiece } from '@fortawesome/free-solid-svg-icons'; +import PremadeCard from './PremadeCard'; import { PresetsEntity } from '../../config'; -export default function PresetsPage(props: { presets: PresetsEntity[] }) { - const { presets } = props; - const hash = location.hash.length < 2 ? undefined : location.hash.slice(1, location.hash.length); +interface PresetsPageProps { + presets: PresetsEntity[]; + /** + * When true (used by Landing.tsx), suppresses the standalone-page hero + * — the surrounding Landing section already supplies its own heading. + */ + embedded?: boolean; +} + +export default function PresetsPage({ presets, embedded = false }: PresetsPageProps) { + const hash = + typeof location !== 'undefined' && location.hash.length >= 2 + ? location.hash.slice(1) + : undefined; const preset = hash - ? presets.filter((preset) => hash.toLowerCase() === preset.addons[0].toLowerCase())[0] + ? presets.filter((p) => hash.toLowerCase() === p.addons[0].toLowerCase())[0] : undefined; + return ( <> - {!preset && ( -
    -

    Download, or edit a preset!

    -

    The download will include a stock set of addons to get you started!

    -
    - )} + {!embedded && !preset && } {preset && ( <> -
    -

    Download, or edit {hash}

    -

    The download will include a stock set of addons to get you started!

    -
    + {!embedded && ( +
    +

    + Featured preset +

    +

    + {hash} +

    +

    + Download as-is, or customize the addon list before bundling. +

    +
    + )} -
    -

    Or download these other awesome presets!

    -
    + {!embedded && ( +
    + + + More presets + + +
    + )} )} -
    + +
    {presets - .filter((preset) => !preset || hash != preset.addons[0]) - .map((preset) => { - return ( - - ); - })} + .filter((p) => !preset || hash !== p.addons[0]) + .map((p) => ( + + ))}
    ); } + +function Hero({ presetCount }: { presetCount: number }) { + return ( +
    +
    +
    +

    + Bundle in one click +

    +

    + Pick a preset. +
    + Drop in your{' '} + + plugins/ + {' '} + folder. +

    +

    + Each preset is a curated bundle of BentoBox + addons that work well together. + Download a ZIP, or customize the addon list first. +

    +
    + + + Browse {presetCount} presets + + + + Build your own + +
    +
    + +
    +

    + How it works +

    +
      + {[ + { n: '1', t: 'Pick a preset or build a custom bundle' }, + { n: '2', t: 'We pull matching JARs from GitHub & CI' }, + { n: '3', t: 'You get a ZIP with an install guide' }, + ].map((step) => ( +
    1. + + {step.n} + + + {step.t} + +
    2. + ))} +
    + + + Or browse community addons → + +
    +
    +
    +
    + ); +} diff --git a/src/web/components/Privacy.tsx b/src/web/components/Privacy.tsx new file mode 100644 index 0000000..329804e --- /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 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..ba87150 --- /dev/null +++ b/src/web/components/Submit.tsx @@ -0,0 +1,2503 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import { + faSignOutAlt, + faUpload, + faCheck, + faArrowRight, + faExternalLinkAlt, + faTimes, +} from '@fortawesome/free-solid-svg-icons'; +import { + GetBlueprints, + GetMe, + PostLogout, + PostSubmitBlueprint, + SessionUser, + SubmitError, +} from '../ApiRequestManager'; +import { BlueprintCatalog } from '../../config'; +import { takePendingBlueprint } from '../pendingBlueprint'; +import VoxelStack, { + BlueprintVoxels, + deriveVoxels, + RealVoxel, + variantFor, + Variant, +} from './VoxelStack'; + +const NAME_REGEX = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const MAX_TAGS = 6; +const MAX_DESCRIPTION_LINES = 10; +const SUGGESTED_TAGS = [ + 'overworld', + 'nether', + 'end', + 'tree', + 'tower', + 'cave', + 'underwater', + 'starter', + 'small', + 'large', + 'sinking', +]; + +/* Default swatches when the catalog overlay doesn't supply one. Same hexes + * the design prototype used. */ +const GM_SWATCH: Record = { + BSkyBlock: '#cfe7d4', + AcidIsland: '#a8d6ee', + AOneBlock: '#e8d49e', + CaveBlock: '#a09689', + Boxed: '#bfa6c9', + Poseidon: '#7fb3cc', + SkyGrid: '#cfe7f2', + Brix: '#e8b34a', + Parkour: '#ffdd57', +}; + +interface ParsedBlueprint { + name?: string; + displayName?: string; + description?: string[]; + icon?: string; + xSize?: number; + ySize?: number; + zSize?: number; + sink?: boolean; + blocks?: unknown[]; + attached?: unknown[]; + entities?: unknown[]; +} + +interface DroppedFile { + file: File; + json: ParsedBlueprint; + derivedSlug: string; +} + +interface ParsedSummary { + dim: { x: number; y: number; z: number }; + blocks: number; + entities: number; + biome: string; + sinking: boolean; + topBlocks: { material: string; count: number }[]; +} + +/* ── Helpers ───────────────────────────────────────────────────── */ + +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 deriveSlug(filename: string): string { + let stem = filename.replace(/\.blueprint$/i, ''); + stem = stem.replace(/\s+/g, '-'); + stem = stem.replace(/[^A-Za-z0-9_.-]/g, ''); + stem = stem.replace(/^[._-]+/, ''); + if (stem.length === 0) stem = 'blueprint'; + if (stem.length > 64) stem = stem.slice(0, 64); + return stem; +} + +function countEntities(entities: unknown[] | undefined): number { + if (!Array.isArray(entities)) return 0; + return entities.reduce((sum: number, pair) => { + const list = Array.isArray(pair) ? (pair as unknown[])[1] : null; + return sum + (Array.isArray(list) ? list.length : 0); + }, 0); +} + +/* The blueprint file format pairs each occupied position with a + * { blockData: 'minecraft:oak_planks[axis=y]', biome?: string } + * record. Walk the list, strip block-state suffixes, count materials. */ +function summarizeBlueprint(json: ParsedBlueprint): ParsedSummary { + const blocks = Array.isArray(json.blocks) ? json.blocks : []; + const matCounts: Record = {}; + const biomeCounts: Record = {}; + for (const entry of blocks) { + if (!Array.isArray(entry) || entry.length < 2) continue; + const block = entry[1] as { blockData?: string; biome?: string } | null; + if (!block) continue; + if (typeof block.blockData === 'string') { + const mat = block.blockData.split('[')[0]; + matCounts[mat] = (matCounts[mat] || 0) + 1; + } + if (typeof block.biome === 'string') { + biomeCounts[block.biome] = (biomeCounts[block.biome] || 0) + 1; + } + } + const topBlocks = Object.entries(matCounts) + .map(([material, count]) => ({ material, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + const topBiome = Object.entries(biomeCounts).sort((a, b) => b[1] - a[1])[0]?.[0]; + return { + dim: { + x: Number.isFinite(json.xSize) ? (json.xSize as number) : 0, + y: Number.isFinite(json.ySize) ? (json.ySize as number) : 0, + z: Number.isFinite(json.zSize) ? (json.zSize as number) : 0, + }, + blocks: blocks.length, + entities: countEntities(json.entities), + biome: topBiome ? topBiome.replace(/^minecraft:/, '') : '—', + sinking: !!json.sink, + topBlocks, + }; +} + +function swatchForGameMode(gm: string, catalog: BlueprintCatalog | null): string { + const overlay = catalog?.gameModes?.[gm]?.color; + if (overlay) return overlay; + return GM_SWATCH[gm] || '#7fb3cc'; +} + +/* ── Page entry: auth gate ─────────────────────────────────────── */ + +export default function SubmitPage(): JSX.Element { + const [user, setUser] = useState(undefined); + const [catalog, setCatalog] = useState(null); + + useEffect(() => { + GetMe() + .then(setUser) + .catch(() => setUser(null)); + GetBlueprints() + .then(setCatalog) + .catch(() => setCatalog(null)); + }, []); + + return ( +
    + {user === undefined ? ( + + ) : user === null ? ( + + ) : ( + setUser(null)} + /> + )} +
    + ); +} + +function Loading(): JSX.Element { + return ( +
    + Loading… +
    + ); +} + +function LoginGate(): JSX.Element { + return ( + <> + +
    +
    +
    + Sign in to submit a blueprint +
    +

    + Your submission becomes a pull request on the BentoBoxWorld weblink + repo for a maintainer to review. By submitting you agree to our{' '} + + Terms + {' '} + and{' '} + + Privacy Policy + + . +

    + + + Login with Discord + +
    +
    + + ); +} + +/* ── Hero ──────────────────────────────────────────────────────── */ + +function SubmitHero({ + step, + hasFile, + signedIn, + user, + onLogout, +}: { + step: 1 | 2 | 3; + hasFile: boolean; + signedIn: boolean; + user?: SessionUser; + onLogout?: () => void; +}): JSX.Element { + return ( +
    +
    + {/* Breadcrumb */} +
    + + Blueprints + + / + Submit +
    + +
    +
    +
    + + Sheet 02 · New entry +
    +

    + Submit a blueprint. +

    +

    + We parse the file, you fill in the metadata, and we open a pull + request against{' '} + + BentoBoxWorld/weblink + {' '} + for a maintainer to review. +

    +
    + + {signedIn && user ? ( +
    + {user.avatarUrl ? ( + + ) : ( +
    + {(user.username || '?').slice(0, 1).toUpperCase()} +
    + )} +
    + + {user.globalName || user.username} + + + via Discord + +
    + +
    + ) : null} +
    + + {/* Stepper */} +
    + {[ + { n: '01', label: 'Upload file', done: hasFile, active: step === 1 }, + { + n: '02', + label: 'Add metadata', + done: step === 3, + active: step === 2, + }, + { + n: '03', + label: 'Review & PR', + done: false, + active: step === 3, + }, + ].map((s, i, arr) => ( + +
    + + {s.done ? ( + + ) : ( + s.n + )} + + + {s.label} + +
    + {i < arr.length - 1 && ( + + )} +
    + ))} +
    +
    +
    + ); +} + +/* ── Main form ─────────────────────────────────────────────────── */ + +function SubmitForm({ + user, + catalog, + onLogout, +}: { + user: SessionUser; + catalog: BlueprintCatalog | null; + onLogout: () => void; +}): JSX.Element { + const [dropped, setDropped] = useState(null); + const [dropError, setDropError] = useState(null); + + const [gameMode, setGameMode] = useState(''); + const [name, setName] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState([]); + const [license, setLicense] = useState('EPL-2.0'); + const [author, setAuthor] = useState(''); + const [accepted, setAccepted] = useState(false); + const [busy, setBusy] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + + const gameModeOptions = useMemo(() => { + const fromCatalog = catalog + ? [ + ...new Set([ + ...Object.keys(catalog.gameModes || {}), + ...catalog.blueprints.map((b) => b.gameMode), + ]), + ] + : []; + return fromCatalog.sort(); + }, [catalog]); + + const tagColors = useMemo(() => { + const out: Record = {}; + for (const [t, def] of Object.entries(catalog?.tags || {})) out[t] = def.color; + return out; + }, [catalog]); + + /* Default the credit-as field to the signed-in user the first time we render. */ + useEffect(() => { + if (!author) setAuthor(user.globalName || user.username || ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function acceptFile(file: File) { + setDropError(null); + if (!file.name.toLowerCase().endsWith('.blueprint')) { + setDropError(`File must end in .blueprint (got "${file.name}").`); + return; + } + if (file.size > MAX_FILE_SIZE) { + setDropError( + `File is ${(file.size / 1024 / 1024).toFixed(1)} MB; the limit is ${ + MAX_FILE_SIZE / 1024 / 1024 + } MB.`, + ); + return; + } + let json: ParsedBlueprint; + try { + const text = await file.text(); + json = JSON.parse(text); + } catch { + setDropError('File is not valid JSON.'); + return; + } + if ( + typeof json !== 'object' || + json === null || + !Number.isFinite(json.xSize) || + !Number.isFinite(json.ySize) || + !Number.isFinite(json.zSize) || + !Array.isArray(json.blocks) + ) { + setDropError( + "That doesn't look like a blueprint (missing xSize/ySize/zSize or blocks).", + ); + return; + } + const slug = deriveSlug(file.name); + setDropped({ file, json, derivedSlug: slug }); + setName((cur) => cur || slug); + setDisplayName((cur) => cur || json.displayName || json.name || slug); + if (Array.isArray(json.description) && json.description.length) { + setDescription((cur) => cur || (json.description as string[]).join('\n')); + } + } + + /* Pick up file handed off from Blueprints page (in-memory or sessionStorage). */ + useEffect(() => { + const incoming = takePendingBlueprint(); + if (incoming) { + void acceptFile(incoming); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function clearFile() { + setDropped(null); + setDropError(null); + /* Reset only the file-derived fields so the next drop's + * `cur || …` prefill in acceptFile() actually takes effect. + * User-entered fields (gameMode, tags, license, credit-as, + * terms checkbox) are preserved. */ + setName(''); + setDisplayName(''); + setDescription(''); + } + + async function handleLogout() { + try { + await PostLogout(user.csrfToken); + } finally { + onLogout(); + } + } + + const slugValid = NAME_REGEX.test(name); + const slugInvalid = name.length > 0 && !slugValid; + const descriptionLines = description.split('\n').length; + const descriptionOverflow = descriptionLines > MAX_DESCRIPTION_LINES; + + const checklist = [ + { ok: !!dropped, label: 'Blueprint file uploaded' }, + { ok: slugValid, label: 'Slug is valid (a–z, 0–9, dot, dash, underscore)' }, + { ok: displayName.trim().length >= 2, label: 'Display name set' }, + { ok: !!gameMode, label: 'Game mode selected' }, + { + ok: description.trim().length > 0 && !descriptionOverflow, + label: `Description (≤ ${MAX_DESCRIPTION_LINES} lines)`, + }, + { ok: tags.length >= 1, label: 'At least one tag' }, + { ok: accepted, label: 'Terms & Privacy accepted' }, + ]; + const checklistDone = checklist.filter((c) => c.ok).length; + const canSubmit = + !busy && + !!dropped && + slugValid && + displayName.trim().length >= 2 && + !!gameMode && + description.trim().length > 0 && + !descriptionOverflow && + tags.length >= 1 && + accepted; + + async function submit() { + if (!dropped || !canSubmit) return; + setBusy(true); + setError(null); + setSuccess(null); + try { + const result = await PostSubmitBlueprint( + user.csrfToken, + { + gameMode, + name, + displayName, + description, + tags: tags.join(','), + }, + dropped.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); + } + } + + if (success) { + return ( + { + setSuccess(null); + setError(null); + setDropped(null); + setName(''); + setDisplayName(''); + setDescription(''); + setTags([]); + setAccepted(false); + }} + /> + ); + } + + const summary: ParsedSummary | null = useMemo( + () => (dropped ? summarizeBlueprint(dropped.json) : null), + [dropped], + ); + + /* Real voxels for the live card preview. Computed once per file + * so re-renders from typing don't re-walk the parsed JSON. */ + const voxels: RealVoxel[] = useMemo( + () => (dropped ? deriveVoxels(dropped.json.blocks) : []), + [dropped], + ); + + const gameModeLower = gameMode ? gameMode.toLowerCase() : 'gamemode'; + const slugForPath = name || 'slug'; + + return ( + <> + + +
    +
    + {/* LEFT — form */} +
    + {/* File area */} +
    + File + {dropped && summary ? ( + + ) : ( + void acceptFile(f)} /> + )} + {dropError && ( +
    + {dropError} +
    + )} +
    + + {/* Metadata card */} +
    +
    + + Metadata +
    + + {/* Game mode */} +
    + + Game mode + + +
    + + {/* Slug + display name */} +
    +
    + + Name + + setName(e.target.value)} + spellCheck={false} + style={bpMonoInputStyle({ invalid: slugInvalid })} + /> + + {slugInvalid + ? 'Use only A–Z, a–z, 0–9, dots, dashes, underscores. 1–64 chars.' + : 'Becomes the filename in the catalog.'} + +
    +
    + + Display name + + + setDisplayName(e.target.value.slice(0, 60)) + } + style={bpInputStyle()} + /> + Title-case is fine. Up to 60 characters. +
    +
    + + {/* Description */} +
    + + Description + + +
    + + {/* Tags */} +
    + + Tags + + +
    + + {/* License + author */} +
    +
    + License + + + Match the BentoBox repo default unless you have a + reason. + +
    +
    + + Credit as + + setAuthor(e.target.value)} + style={bpMonoInputStyle()} + /> +
    +
    +
    + + {/* Terms */} + + + {error && ( +
    + + Submit failed{error.error ? ` · ${error.error}` : ''} + +
    + {error.reason || 'Try again in a moment.'} +
    +
    + )} + + {/* Spacer so the sticky bar doesn't overlap content */} +
    +
    + + {/* RIGHT — preview rail */} + +
    +
    + + {/* Sticky action bar */} +
    +
    +
    + + {slugForPath}.blueprint + + + will land at{' '} + + weblink/blueprints/{gameModeLower}/{slugForPath}.blueprint + + +
    +
    + +
    +
    + + ); +} + +/* ── Field primitives ──────────────────────────────────────────── */ + +function FieldLabel({ + children, + hint, + required, +}: { + children: React.ReactNode; + hint?: string; + required?: boolean; +}): JSX.Element { + return ( +
    + + {hint && ( + + {hint} + + )} +
    + ); +} + +function bpInputStyle({ invalid }: { invalid?: boolean } = {}): React.CSSProperties { + return { + width: '100%', + padding: '11px 13px', + background: 'rgba(10, 29, 51, 0.55)', + border: `1px solid ${invalid ? 'var(--bb-rose)' : 'var(--bp-line-strong)'}`, + borderRadius: 10, + color: 'var(--bp-ink)', + fontFamily: 'var(--bb-sans)', + fontSize: 14, + outline: 'none', + transition: 'border-color 120ms ease, background 120ms ease', + }; +} +function bpMonoInputStyle(o?: { invalid?: boolean }): React.CSSProperties { + return { ...bpInputStyle(o), fontFamily: 'var(--bb-mono)', fontSize: 13 }; +} +function bpSelectStyle(): React.CSSProperties { + return { + ...bpInputStyle(), + appearance: 'none', + WebkitAppearance: 'none', + cursor: 'pointer', + backgroundImage: + 'linear-gradient(45deg, transparent 50%, var(--bp-ink-soft) 50%), linear-gradient(135deg, var(--bp-ink-soft) 50%, transparent 50%)', + backgroundPosition: 'calc(100% - 18px) 18px, calc(100% - 13px) 18px', + backgroundSize: '5px 5px, 5px 5px', + backgroundRepeat: 'no-repeat', + paddingRight: 32, + }; +} + +function HelpText({ + children, + tone = 'soft', +}: { + children: React.ReactNode; + tone?: 'soft' | 'error'; +}): JSX.Element { + const color = tone === 'error' ? 'var(--bb-rose)' : 'var(--bp-ink-soft)'; + return ( +
    + {children} +
    + ); +} + +/* ── File receipt ──────────────────────────────────────────────── */ + +function FileReceipt({ + file, + summary, + derivedSlug, + onReplace, +}: { + file: File; + summary: ParsedSummary; + derivedSlug: string; + onReplace: () => void; +}): JSX.Element { + return ( +
    +
    +
    +
    + +
    +
    +
    + + {file.name} + + {humanSize(file.size)} + + {summary.dim.x}×{summary.dim.y}×{summary.dim.z} + +
    + +
    + + + + +
    + +
    + + slug from filename:{' '} + {derivedSlug} + +
    +
    + + +
    +
    + ); +} + +function SpecCell({ + label, + value, + mono, + accent, + last, +}: { + label: string; + value: string; + mono?: boolean; + accent?: boolean; + last?: boolean; +}): JSX.Element { + return ( +
    +
    + {label} +
    +
    + {value} +
    +
    + ); +} + +/* ── Empty drop ────────────────────────────────────────────────── */ + +function EmptyDrop({ onAccept }: { onAccept: (f: File) => void }): JSX.Element { + const [drag, setDrag] = useState(false); + const inputRef = useRef(null); + return ( +
    inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setDrag(true); + }} + onDragLeave={() => setDrag(false)} + onDrop={(e) => { + e.preventDefault(); + setDrag(false); + const file = e.dataTransfer.files?.[0]; + if (file) onAccept(file); + }} + style={{ + cursor: 'pointer', + position: 'relative', + borderRadius: 14, + background: drag ? 'rgba(127,179,204,0.10)' : 'rgba(10, 29, 51, 0.55)', + border: `1.5px dashed ${drag ? 'var(--bp-accent)' : 'var(--bp-line-strong)'}`, + padding: '32px 24px', + display: 'flex', + alignItems: 'center', + gap: 18, + transition: 'background 120ms, border-color 120ms', + flexWrap: 'wrap', + }} + > + { + const file = e.target.files?.[0]; + if (file) onAccept(file); + e.target.value = ''; + }} + /> +
    + +
    +
    +
    + Drop a{' '} + + .blueprint + {' '} + file here +
    +
    + We’ll read its dimensions, block counts, and biomes so you don’t + have to retype them. Max 5 MB. +
    +
    + + Choose file + +
    + ); +} + +/* ── Game mode picker ──────────────────────────────────────────── */ + +function GameModePicker({ + value, + onChange, + options, + catalog, +}: { + value: string; + onChange: (v: string) => void; + options: string[]; + catalog: BlueprintCatalog | null; +}): JSX.Element { + const list = options.length ? options : Object.keys(GM_SWATCH); + return ( +
    + {list.map((gm) => { + const active = value === gm; + const swatch = swatchForGameMode(gm, catalog); + const label = catalog?.gameModes?.[gm]?.displayName || gm; + return ( + + ); + })} +
    + ); +} + +/* ── Description with line counter ─────────────────────────────── */ + +function DescriptionField({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}): JSX.Element { + const lines = value.split('\n').length; + const overflow = lines > MAX_DESCRIPTION_LINES; + const counterColor = overflow + ? 'var(--bb-rose)' + : lines >= MAX_DESCRIPTION_LINES - 1 + ? 'var(--bp-accent)' + : 'var(--bp-ink-soft)'; + return ( +
    +