feat: import custom content blocks

pull/8104/head
NGPixel 1 week ago
parent 023711cd1a
commit c2dc0a1fcb
No known key found for this signature in database

@ -64,7 +64,9 @@ path in silence.
- `controllers/` — non-API HTTP routes. `site.ts` serves per-site resources (logo, favicon, login
background) under `/_site`; `icons.ts` serves icons under `/_icons`, implementing the part of the
Iconify API protocol the frontend speaks (`/_icons/<prefix>.json?icons=a,b` and
`/_icons/<prefix>/<name>.svg`). Public and cached hard — see [Icons](#icons).
`/_icons/<prefix>/<name>.svg`). Public and cached hard — see [Icons](#icons). `blocks.ts` serves
compiled blocks under `/_blocks`, from the built tree or from an imported package depending on the
block and the site — see [Distributing a block](#distributing-a-block).
`rootFiles.ts` is the exception that registers at the root rather than under a prefix: `robots.txt`
and `sitemap.xml`, the two names in `RESERVED_ROOT_FILES` a crawler asks for by convention, both
driven by the site's **General → SEO** settings. The sitemap lists what the GUESTS group may read
@ -133,8 +135,9 @@ to the backend on **3000**, so the backend must be running too.
Self-contained Lit components. Each lives in `blocks/block-<name>/component.js` — the glob in
`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no
config change. Output goes to `blocks/compiled/`, which the backend serves statically under
`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
config change. Output goes to `blocks/compiled/`, which the backend serves under `/_blocks/` —
alongside the blocks a site has imported as packages, see [Distributing a block](#distributing-a-block).
Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
Vite's `dynamicImportVarsOptions`. A block pulling in a heavy library is fine — nothing is fetched
until its tag turns up in a page — and a library that still ships CommonJS works too, since the
rollup config runs `@rollup/plugin-commonjs` after `resolve()`.
@ -162,6 +165,75 @@ A block that must *act* on the change rather than restyle for it passes `onChang
`.isDark` — `block-diagram` redraws mermaid in its own dark theme, `block-map` resolves a per-block
`theme` prop that can pin a map light on a dark page.
### Distributing a block
A block need not ship with the wiki. `npm run package -- block-xyz` compiles one block on its own and
writes `blocks/packages/block-xyz.wkblock`, a single file an administrator uploads under
**Admin → Content Blocks → Import Block**. So the whole of writing one is: clone this repository, add
a directory under `blocks/`, package it, upload it — nothing of the instance is rebuilt or restarted,
and the author never touches the wiki they are writing for.
**A packaged block is the same thing as a built-in one, arriving by a different road.** Same
`component.js`, same `static definition`, same rollup build; what differs is where its files end up
and where its definition is read from. So a block being written is developed in a full checkout —
`npm run build` and the compiled tree — and packaged once it works. There is no second authoring API.
`blocks/package.mjs` is the packager and `backend/helpers/wkblock.ts` reads what it writes. **The
format is stated in full in both files and has to be kept in step by hand**: `blocks/` and `backend/`
are separately installed workspaces and the backend does not type-check JavaScript, so there is no
module the two halves could share.
- **One block per package, and the directory name is the identity.** `blocks/block-xyz/` declares
`block: 'xyz'`, is packaged as `block-xyz.wkblock`, serves as `block-xyz.js` and renders as
`<block-xyz>`. The packager refuses a mismatch, because every one of those names is derived from the
same key. A **child block** (`isChild`) is refused outright: it is part of whatever holds it, has no
row and nothing to switch on, so a package of one would install nothing.
- **Everything is namespaced under the block's own name**, which is what lets an imported block and a
built-in one be served from the same `/_blocks/` without either standing on the other. A package
holds `block-xyz.js`, optionally `block-xyz.worker.js`, and `block-xyz/**` — the assets from its
`assets.json` and, unlike the full build, its shared chunks, which `buildConfig({ only })` names
into that directory rather than leaving at the root. Both the packager and the importer check it.
- **The package is the only copy.** It is stored verbatim on the block's row (`packageData`) with its
definition and a `checksum`. Nothing is written to `blocks/` on the server, which is a build output
and in a container is part of the image.
- **Re-importing the same key is an upgrade, not a second block.** The row is updated, so what the
site had switched on and configured on it survives; the reply says `isNew: false`. A key a built-in
block already uses is refused with 409 rather than shadowing it.
- **`manage:sites` is what it takes**, the same permission the screen already needs — and deliberately
not something stricter. A block is code that runs in every reader's browser on that site, which is
exactly what the raw head and body fields under **Admin → Theme** already are. It is not a new kind
of power, it is a tidier way to exercise one the admin area already grants.
- **The container is parsed as something a stranger uploaded**, because the trust boundary above is
about the CODE, not about the file: every length is bounded before it is acted on, every file's
SHA-256 is checked, and every path has to fall inside the block's namespace.
**The files are served from `/_blocks/` like a built-in's, and `controllers/blocks.ts` is what decides
which.** That route replaced the `@fastify/static` registration for `/_blocks/`, because a static
plugin claims the whole prefix and leaves nothing to ask the question in front of it; the plugin is
still registered with `serve: false`, for `reply.sendFile`.
- **The first path segment names the block, and that is the whole decision** — which is the reason the
namespace above is enforced.
- **It depends on which SITE was asked**, since a custom block belongs to one, and two sites on an
instance may each have imported a different block under the same key. The frontend has no site in
hand when it loads a block (it reads a tag out of the page and asks for it), so the hostname
resolves it, the same `WIKI.sitesMappings` lookup the request hooks do.
- **`<dataPath>/cache/blocks/<siteId>/block-<key>/` is a cache, not storage.** `servingPathFor`
unpacks the stored package into it on the first request and `block-<key>.checksum` beside it says
which version is there — written last, so an unpack that died halfway is done again rather than half
served. Which also means an upgrade reaches every instance of an HA set on its own, including one
that was not running when the upload happened, and a fresh container needs nothing restored.
- **Custom files are revalidated (`no-cache` + ETag), built-ins are held for an hour.** The names are
the same across versions of a custom block, and the point of uploading a fixed one is that the fix
is live.
**A custom block's definition is read from its row wherever a built-in's is read from the manifest** —
its props in `getSiteBlocks`, and its tag and attributes in the sanitiser's allow list
(`getEnabledForRender`, which fetches both in the one query `postProcess` was already making, for the
same reason that query is not cached: a definition this misses is a block stripped out of somebody's
page). Everything else about it is identical, the enable toggle included: a custom block that is
switched off is stripped from a page being saved exactly as a built-in one is.
## Commands
Run backend commands from `backend/`, frontend from `frontend/`, blocks from `blocks/`.
@ -180,7 +252,8 @@ npm run dev # vite dev server on :3001 (needs backend running on :300
npm run build # builds into ../assets — required before the backend can serve the UI
# blocks
npm run build # rollup → blocks/compiled/
npm run build # rollup → blocks/compiled/
npm run package -- block-x # compile one block → blocks/packages/block-x.wkblock
```
`npx ncu -i` (`npm run ncu`) for interactive dependency updates.

@ -1,4 +1,5 @@
import { audit } from '../helpers/audit.ts'
import { MAX_PACKAGE_SIZE } from '../helpers/wkblock.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
@ -58,6 +59,22 @@ async function mayListBlocks(req: FastifyRequest, siteId: string): Promise<boole
* Blocks API Routes
*/
async function routes(app: FastifyInstance) {
/*
A block package is the raw file rather than a multipart form: one file, no fields. The catch-all
only claims content types nothing else parses, so the JSON routes below are unaffected.
The limit is the format's own (`MAX_PACKAGE_SIZE`) rather than the site's upload limit: that one
is about what readers may attach to pages and is usually turned down, while a block carrying a PDF
engine and its character maps is legitimately a couple of dozen megabytes.
*/
app.addContentTypeParser(
'*',
{ parseAs: 'buffer', bodyLimit: MAX_PACKAGE_SIZE },
(req, body, done) => {
done(null, body)
}
)
/**
* LIST SITE BLOCKS
*/
@ -72,7 +89,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'List the blocks available to a site',
description:
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed. This is what the editor builds its block picker from, so it is available to page authors and to anyone an approval rule lets suggest an edit — guests included, where a site takes public suggestions — as well as to site administrators.',
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed. Ordered by name, built-in and imported blocks together, with the child blocks last. This is what the editor builds its block picker from, so it is available to page authors and to anyone an approval rule lets suggest an edit — guests included, where a site takes public suggestions — as well as to site administrators.',
tags: ['Blocks'],
params: {
type: 'object',
@ -200,6 +217,109 @@ async function routes(app: FastifyInstance) {
}
)
/**
* IMPORT A CUSTOM BLOCK
*/
app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/blocks/import',
{
config: {
/*
The same permission that already governs this screen, and deliberately not a stricter one.
A block is code that runs in every reader's browser on this site — which is exactly what the
raw head and body fields under Administration → Theme already are, and those are `manage:theme`.
`manage:sites` is where the trust boundary for markup and script injected into a site's pages
sits; a block is not a new kind of power, it is a tidier way to exercise that one.
*/
permissions: ['manage:sites']
},
schema: {
summary: 'Import a packaged block',
description:
"The body is the `.wkblock` file itself, not a multipart form — send the bytes with `Content-Type: application/octet-stream`. A package is built by cloning this repository, writing a block under `blocks/block-<key>/` and running `npm run package -- block-<key>` in `blocks/`.\n\nThe key inside the package is the identity: importing a package whose key this site already has REPLACES that block, which is how one is upgraded — what the site had switched on and configured on it is kept. A key that a built-in block already uses is refused, since both would be served from the same address.\n\nThe block is registered enabled and is available to authors immediately. Its compiled files are served from `/_blocks/` like a built-in one, unpacked from the stored package into the instance's cache the first time a browser asks for one.",
tags: ['Blocks'],
consumes: ['application/octet-stream'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
200: {
description: 'Block imported successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
id: {
type: 'string',
format: 'uuid',
description: 'The block row, which is the existing one when a block was replaced.'
},
block: {
type: 'string',
description: 'The key it registered under — it renders as `<block-{block}>`.'
},
name: { type: 'string' },
isNew: {
type: 'boolean',
description: 'False when the package replaced a block this site already had.'
},
fileCount: {
type: 'integer',
description: 'How many compiled files the package brought.'
},
packagedAt: {
type: 'string',
description: 'When the package was built, or empty if it did not say.'
},
packagedWith: {
type: 'string',
description: 'The version of Wiki.js that built it, or empty if it did not say.'
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
const data = req.body
if (!Buffer.isBuffer(data) || data.length < 1) {
return reply.badRequest('No block package was sent.')
}
// -> Everything `importPackage` refuses is a `CustomError` carrying its own status and a message
// the administrator who chose the file can act on, so it is left to the error handler
const result = await WIKI.models.blocks.importPackage(req.params.siteId, data)
await audit(req, 'admin', 'importBlock', {
siteId: req.params.siteId,
blockId: result.id,
block: result.block,
name: result.name,
isNew: result.isNew,
packagedWith: result.packagedWith
})
return {
ok: true,
message: result.isNew ? 'Block imported successfully.' : 'Block updated successfully.',
...result
}
}
)
/**
* DELETE CUSTOM BLOCK
*/

@ -31,7 +31,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
isCustom: {
type: 'boolean',
description: 'False for blocks registered from the compiled block manifest.'
description:
'False for blocks registered from the compiled block manifest. True for one imported as a `.wkblock` package, which is stored in the database and may be deleted.'
},
isChild: {
type: 'boolean',
@ -60,7 +61,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
props: {
type: 'array',
description:
"The block's authorable attributes, as its component declares them — what the editor's block picker turns into a form. Read from the compiled manifest rather than the database, so it describes the code that is installed. Empty for a custom block, which has no manifest entry.",
"The block's authorable attributes, as its component declares them — what the editor's block picker turns into a form. Describes the code that is installed rather than a copy of it made when the block was registered: read from the compiled manifest for a built-in block, and from the definition stored with its package for an imported one.",
items: {
type: 'object',
properties: {

@ -0,0 +1,75 @@
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
/**
* _blocks Routes — the compiled web components a page's blocks are drawn by.
*
* Two places answer from here and the URL does not say which: `/_blocks/block-diagram.js` is a file
* in `blocks/compiled` when the block ships with the wiki, and a file unpacked from a row in the
* database when somebody imported it. The first segment of the path names the block, and that is the
* whole of the decision — everything a block brings is under `block-<key>.js`,
* `block-<key>.worker.js` or `block-<key>/`, which is the namespace `helpers/wkblock.ts` holds an
* imported package to precisely so that this can be settled by looking at one segment.
*
* Which means the answer depends on WHICH SITE was asked, since a custom block belongs to one site,
* and two sites on an instance may each have imported a different block under the same key. The
* frontend has no site in hand when it loads a block — it reads a tag out of the page and asks for it
* — so the hostname is what resolves it, the same lookup every request hook does.
*
* This is a plain route rather than a second `@fastify/static` registration because a static plugin
* claims `/_blocks/*` outright, leaving nothing to ask the question in front of it. The plugin is
* still registered, with `serve: false`, for `reply.sendFile` and everything it knows about ranges,
* conditional requests and content types.
*/
async function routes(app: FastifyInstance) {
const builtInRoot = path.join(WIKI.ROOTPATH, 'blocks/compiled')
app.register(fastifyStatic, {
root: builtInRoot,
serve: false
})
app.get<{ Params: { '*': string } }>('/*', async (req, reply) => {
const filePath = req.params['*'] ?? ''
const block = blockKeyOf(filePath)
const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*']
const customRoot =
block && siteId ? await WIKI.models.blocks.servingPathFor(siteId, block) : null
if (!customRoot) {
return reply.sendFile(filePath, builtInRoot, { maxAge: '1h' })
}
/*
Revalidated rather than held for an hour like a built-in. The file names are the same across
versions — `block-xyz.js` is `block-xyz.js` however many times it has been re-imported — and
the whole point of uploading a fixed block is that the fix is live. An ETag turns nearly every
one of these into an empty 304, which is what makes that affordable.
*/
reply.header('Cache-Control', 'public, no-cache')
return reply.sendFile(filePath, customRoot, { cacheControl: false })
})
}
/**
* The block a served path belongs to, or null for a path that names no block.
*
* `block-pdf.js`, `block-pdf.worker.js` and `block-pdf/cmaps/Adobe-Japan1-0.bcmap` are all the pdf
* block. Anything else — `blocks.manifest.json`, a shared chunk of the built-in build — is nobody's,
* and is a built-in file by elimination.
*/
function blockKeyOf(filePath: string): string | null {
const first = filePath.split('/')[0]
if (!first?.startsWith('block-')) {
return null
}
const stem = first.endsWith('.worker.js')
? first.slice(0, -'.worker.js'.length)
: first.endsWith('.js')
? first.slice(0, -'.js'.length)
: first
return /^block-[a-z0-9][a-z0-9-]*$/.test(stem) ? stem.slice('block-'.length) : null
}
export default routes

@ -45,7 +45,7 @@ export default {
/**
* Throw away everything this instance holds that the database is the real copy of.
*
* The file and icon caches, in memory and on disk, and the site, group, page rule and locale state
* The file, icon and block caches, in memory and on disk, and the site, group, page rule and locale state
* that answers every request. Nothing is lost and nothing is turned off: what the caches held is
* read back from the database as it is asked for again, and the four reloaded here are refilled
* before this returns rather than left for the next visitor to pay for.
@ -54,6 +54,7 @@ export default {
WIKI.cache.flushAll()
await WIKI.models.assets.purgeCache()
await WIKI.models.icons.purgeCache()
await WIKI.models.blocks.purgeCache()
await WIKI.models.locales.reloadCache()
await WIKI.models.sites.reloadCache()
@ -78,5 +79,11 @@ export default {
WIKI.events.inbound.on('reloadLocales', async () => {
await WIKI.models.locales.reloadCache()
})
// -> Which blocks a site imported is held per instance too, and is what `/_blocks` answers from.
// The files themselves need no propagating: each instance unpacks a block it has not got, or
// has at the wrong checksum, the first time a browser asks it for one.
WIKI.events.inbound.on('reloadBlocks', async () => {
await WIKI.models.blocks.refreshCustomIndex()
})
}
}

@ -0,0 +1,3 @@
ALTER TABLE "blocks" ADD COLUMN "definition" jsonb DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "blocks" ADD COLUMN "packageData" bytea;--> statement-breakpoint
ALTER TABLE "blocks" ADD COLUMN "checksum" varchar(64) DEFAULT '' NOT NULL;

File diff suppressed because it is too large Load Diff

@ -192,6 +192,19 @@ export const authentication = pgTable('authentication', {
})
// BLOCKS ------------------------------
/**
* One block available to one site — `<block-diagram>` on this wiki.
*
* A built-in block's row is metadata only: what it can do comes from the compiled manifest, which
* `models/blocks.ts` reads off disk at boot and reconciles against these rows. A CUSTOM block has no
* disk to read, so the three columns below carry the whole of it — what it declares, and the bytes
* that draw it. See `helpers/wkblock.ts` for the package they arrived in.
*
* Per site, package and all: a `.wkblock` imported into three sites is stored three times. That is
* the same answer every other per-site setting gives, and the alternative — one shared copy with the
* sites counted off it — buys a few megabytes at the cost of one site's upgrade changing another's
* block.
*/
export const blocks = pgTable(
'blocks',
{
@ -203,6 +216,28 @@ export const blocks = pgTable(
isEnabled: boolean().notNull().default(false),
isCustom: boolean().notNull().default(false),
config: jsonb().notNull().default({}),
/**
* What a CUSTOM block declares — its props, its template, its content editor — as its package's
* copy of the component's `static definition`. Empty for a built-in, whose definition is read
* from the compiled manifest instead, so that an updated block describes itself correctly the
* moment it is deployed rather than whenever a row was last written.
*/
definition: jsonb().notNull().default({}),
/**
* The `.wkblock` file a custom block was imported from, kept verbatim. Null for a built-in.
*
* This is the only copy: the files served to a browser are unpacked from it into
* `<dataPath>/cache/blocks`, which is a cache and starts empty on a fresh container.
*/
packageData: bytea(),
/**
* SHA-256 of that package, and the name of its directory in the disk cache.
*
* Which is what makes re-importing a block take effect: the files on disk are stale exactly when
* they were unpacked under a different digest, and every instance in an HA set works that out for
* itself without being told. Empty for a built-in.
*/
checksum: varchar({ length: 64 }).notNull().default(''),
siteId: uuid()
.notNull()
.references(() => sites.id)

@ -0,0 +1,322 @@
import crypto from 'node:crypto'
import { gunzipSync } from 'node:zlib'
import { CustomError } from './common.ts'
import type { BlockDefinition, BlockProp } from '../models/blocks.ts'
/**
* Reading a `.wkblock` — the single file a block is distributed as.
*
* `blocks/package.mjs` is the other half of this, and the two have to agree. There is no module to
* share between them: `blocks/` and `backend/` are separately installed workspaces and the backend
* does not type-check JavaScript, so the format is written twice and stated in full in both places.
*
* magic 8 bytes "WKBLOCK\0"
* version uint32be format version, 1
* headerLen uint32be byte length of the header that follows
* header gzip'd JSON — see `PackageHeader`
* payload each file's gzip'd bytes, concatenated in the header's order
*
* Everything here treats the package as something a person uploaded, because that is what it is:
* `manage:sites` is the trust boundary for the CODE in it — which runs in every reader's browser on
* that site, and is no more and no less than what the raw head and body fields under Theme already
* allow — but the container itself is parsed before anybody has vouched for anything. So every
* length is bounded before it is acted on, every digest is checked, and every path has to fall inside
* the block's own namespace.
*/
const MAGIC = Buffer.from('WKBLOCK\0', 'latin1')
const FORMAT_VERSION = 1
const PREAMBLE_SIZE = 16
/**
* The most a `.wkblock` may weigh, and the body limit of the route that takes one.
*
* Deliberately not the site's asset upload limit: that one is about what readers may attach to
* pages and is usually turned down, while a block carrying a PDF engine and its character maps is
* legitimately a couple of dozen megabytes.
*/
export const MAX_PACKAGE_SIZE = 32 * 1024 * 1024
/** Bounds on what the container may claim, all checked before anything is decompressed. */
const MAX_HEADER_SIZE = 4 * 1024 * 1024
const MAX_UNPACKED_SIZE = 128 * 1024 * 1024
const MAX_FILE_COUNT = 4096
/** A block key, which is also a file name and the suffix of an element. */
const BLOCK_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
/**
* A prop name, which becomes an attribute the sanitiser allows on the block's tag.
*
* Attribute names are what the allow list is built from, so a name that is not one would either be
* dropped silently or widen that list in a way nobody wrote down.
*/
const PROP_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,63}$/
const PROP_TYPES = new Set(['string', 'number', 'boolean', 'select', 'icon'])
interface PackageFileEntry {
path: string
size: number
compressedSize: number
sha256: string
}
interface PackageHeader {
block: string
definition: BlockDefinition
packagedAt?: string
packagedWith?: string
files: PackageFileEntry[]
}
/** A package read, checked and unpacked in memory, ready to be stored and written to the cache. */
export interface BlockPackage {
/** The block key — the suffix of `<block-xyz>`, and the stem of every path below. */
block: string
definition: BlockDefinition
/** The files to serve, keyed by the path they are served at, relative to `/_blocks/`. */
files: Map<string, Buffer>
packagedAt: string
packagedWith: string
}
function refuse(message: string): never {
throw new CustomError('blockPackageInvalid', message)
}
/**
* Whether a path is one this package is allowed to bring.
*
* Two things at once, and both matter. It has to be a plain relative path, since it is joined onto a
* cache directory — no root, no `..`, no backslashes (a Windows instance would read one as a
* separator where this check would not). And it has to sit inside the block's own namespace, which
* is what stops an imported block from standing on a built-in one: the serving route decides which
* root answers a request from the first segment of the path alone, so a package holding
* `block-diagram/foo.js` would answer for a block it is not.
*/
function isServablePath(filePath: string, blockDir: string): boolean {
if (
!filePath ||
filePath.length > 255 ||
filePath.includes('\\') ||
filePath.startsWith('/') ||
/(^|\/)\.\.?(\/|$)/.test(filePath) ||
filePath.endsWith('/') ||
filePath.includes('//') ||
[...filePath].some((char) => char.codePointAt(0)! < 0x20)
) {
return false
}
return (
filePath === `${blockDir}.js` ||
filePath === `${blockDir}.worker.js` ||
filePath.startsWith(`${blockDir}/`)
)
}
/**
* The definition as the package declares it, with everything the wiki will act on checked.
*
* Read rather than trusted, and rebuilt key by key rather than spread: what comes back is stored on
* the block's row, handed to the editor to build a form from, and turned into the sanitiser's
* allow list for the block's tag. An unknown key would travel all of that way meaning nothing.
*/
function readDefinition(raw: any, block: string): BlockDefinition {
if (!raw || typeof raw !== 'object' || raw.block !== block) {
refuse("The package's definition does not describe the block it claims to be.")
}
if (raw.isChild) {
refuse(
'This is a child block — one that only ever appears inside another. It has nothing to be installed or switched on separately from whatever holds it.'
)
}
const text = (value: unknown, field: string, max: number): string => {
if (typeof value !== 'string' || value.length > max) {
refuse(`The package's definition has no usable "${field}".`)
}
return value
}
const definition: BlockDefinition = {
block,
name: text(raw.name, 'name', 255),
description: text(raw.description ?? '', 'description', 255),
icon: text(raw.icon ?? '', 'icon', 255),
props: readProps(raw.props)
}
if (raw.template) {
definition.template = text(raw.template, 'template', 8192)
}
if (raw.asciidocTemplate) {
definition.asciidocTemplate = text(raw.asciidocTemplate, 'asciidocTemplate', 8192)
}
if (raw.contentEditor) {
definition.contentEditor = text(raw.contentEditor, 'contentEditor', 64)
}
return definition
}
function readProps(raw: any): BlockProp[] {
if (raw === undefined || raw === null) {
return []
}
if (!Array.isArray(raw) || raw.length > 64) {
refuse('The package\'s definition declares an unusable "props" list.')
}
return raw.map((prop: any) => {
if (!prop || typeof prop !== 'object' || !PROP_NAME_PATTERN.test(prop.name ?? '')) {
refuse(`"${prop?.name}" is not a usable prop name — it becomes an attribute on the block.`)
}
if (!PROP_TYPES.has(prop.type)) {
refuse(`Prop "${prop.name}" has no usable type.`)
}
const checked: BlockProp = { name: prop.name, type: prop.type }
for (const field of ['label', 'hint'] as const) {
if (typeof prop[field] === 'string') {
checked[field] = prop[field].slice(0, 1024)
}
}
if (prop.required === true) {
checked.required = true
}
if (['string', 'number', 'boolean'].includes(typeof prop.default)) {
checked.default = prop.default
}
if (Array.isArray(prop.options)) {
checked.options = prop.options
.slice(0, 128)
.map((option: any) =>
typeof option === 'string'
? option
: { label: String(option?.label ?? ''), value: String(option?.value ?? '') }
)
}
return checked
})
}
/**
* Read a `.wkblock` file.
*
* Throws a `CustomError` naming what is wrong with it, since every one of these is something the
* administrator who uploaded the file can act on — a truncated download, the wrong file, a package
* built by a newer wiki.
*/
export function readBlockPackage(data: Buffer): BlockPackage {
if (!Buffer.isBuffer(data) || data.length < PREAMBLE_SIZE || !data.subarray(0, 8).equals(MAGIC)) {
refuse(
'Not a Wiki.js block package. A packaged block is a .wkblock file built by "npm run package" in blocks/.'
)
}
const version = data.readUInt32BE(8)
if (version !== FORMAT_VERSION) {
refuse(
`This package is in block format ${version}, and this wiki reads format ${FORMAT_VERSION}. It was most likely built by a different version of Wiki.js.`
)
}
const headerLength = data.readUInt32BE(12)
if (
headerLength < 1 ||
headerLength > MAX_HEADER_SIZE ||
PREAMBLE_SIZE + headerLength > data.length
) {
refuse('The package is damaged: its table of contents does not fit inside it.')
}
let header: PackageHeader
try {
header = JSON.parse(
gunzipSync(data.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength), {
maxOutputLength: MAX_HEADER_SIZE
}).toString('utf8')
)
} catch {
refuse('The package is damaged: its table of contents could not be read.')
}
const block = header.block
if (typeof block !== 'string' || !BLOCK_KEY_PATTERN.test(block)) {
refuse(
`"${block}" is not a usable block key — it has to be lowercase letters, digits and dashes.`
)
}
const blockDir = `block-${block}`
const definition = readDefinition(header.definition, block)
if (
!Array.isArray(header.files) ||
header.files.length < 1 ||
header.files.length > MAX_FILE_COUNT
) {
refuse('The package lists no files, or more than a block can hold.')
}
// -> Everything the header CLAIMS is checked before a byte of the payload is touched, so that a
// package cannot talk this into decompressing more than it is prepared to hold
let unpackedSize = 0
let payloadSize = 0
for (const entry of header.files) {
if (!isServablePath(entry?.path, blockDir)) {
refuse(
`The package holds "${entry?.path}", which is outside ${blockDir}'s own files. A block may only bring ${blockDir}.js, ${blockDir}.worker.js and ${blockDir}/**.`
)
}
if (
!Number.isInteger(entry.size) ||
entry.size < 0 ||
!Number.isInteger(entry.compressedSize) ||
entry.compressedSize < 0 ||
typeof entry.sha256 !== 'string' ||
!/^[0-9a-f]{64}$/.test(entry.sha256)
) {
refuse(`The package's entry for "${entry.path}" is damaged.`)
}
unpackedSize += entry.size
payloadSize += entry.compressedSize
}
if (unpackedSize > MAX_UNPACKED_SIZE) {
refuse('The package unpacks to more than a block is allowed to hold.')
}
if (PREAMBLE_SIZE + headerLength + payloadSize !== data.length) {
refuse(
'The package is damaged: its contents do not match the length its table of contents states.'
)
}
const files = new Map<string, Buffer>()
let offset = PREAMBLE_SIZE + headerLength
for (const entry of header.files) {
if (files.has(entry.path)) {
refuse(`The package holds "${entry.path}" twice.`)
}
let bytes: Buffer
try {
bytes = gunzipSync(data.subarray(offset, offset + entry.compressedSize), {
maxOutputLength: Math.max(entry.size, 1)
})
} catch {
refuse(`The package is damaged: "${entry.path}" could not be decompressed.`)
}
offset += entry.compressedSize
if (
bytes.length !== entry.size ||
crypto.createHash('sha256').update(bytes).digest('hex') !== entry.sha256
) {
refuse(`The package is damaged: "${entry.path}" is not what its checksum says it is.`)
}
files.set(entry.path, bytes)
}
if (!files.has(`${blockDir}.js`)) {
refuse(`The package has no ${blockDir}.js, which is the file the wiki loads the block from.`)
}
return {
block,
definition,
files,
packagedAt: typeof header.packagedAt === 'string' ? header.packagedAt : '',
packagedWith: typeof header.packagedWith === 'string' ? header.packagedWith : ''
}
}

@ -200,6 +200,8 @@ async function postBoot() {
// -> Must follow the sites cache: every site gets a row per installed block
await WIKI.models.blocks.refreshFromDisk()
await WIKI.models.blocks.syncAllSites()
// -> And which blocks were imported rather than installed, which is what `/_blocks` answers from
await WIKI.models.blocks.refreshCustomIndex()
// -> Same: every site gets a row per installed storage module
await WIKI.models.storage.refreshFromDisk()
@ -395,17 +397,6 @@ async function initHTTPServer() {
decorateReply: false
})
// ----------------------------------------
// Blocks
// ----------------------------------------
app.register(fastifyStatic, {
prefix: '/_blocks/',
root: path.join(WIKI.ROOTPATH, 'blocks/compiled'),
index: false,
maxAge: '1h'
})
// ----------------------------------------
// Sessions
// ----------------------------------------
@ -734,6 +725,7 @@ async function initHTTPServer() {
// })
app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/blocks.ts'), { prefix: '/_blocks' })
app.register(import('./controllers/collab.ts'), { prefix: '/_collab' })
app.register(import('./controllers/files.ts'), { prefix: '/_files' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' })

@ -168,6 +168,7 @@
"admin.audit.actions.flushCache": "Flushed the caches",
"admin.audit.actions.flushIconCache": "Purged the icon cache",
"admin.audit.actions.forcedPasswordChange": "Changed a password when required to at sign-in",
"admin.audit.actions.importBlock": "Imported a custom block",
"admin.audit.actions.installExtension": "Installed an extension",
"admin.audit.actions.installLocale": "Installed a locale",
"admin.audit.actions.invalidateSessions": "Ended every session",
@ -347,13 +348,16 @@
"admin.auth.unsaved": "Not saved",
"admin.auth.vendor": "Vendor",
"admin.auth.vendorWebsite": "Website",
"admin.blocks.add": "Add Block",
"admin.blocks.addUnavailable": "Adding custom blocks is not implemented yet.",
"admin.blocks.builtin": "Built-in",
"admin.blocks.custom": "Custom",
"admin.blocks.delete": "Delete Block",
"admin.blocks.deleteConfirm": "Are you sure you want to delete the custom block {blockName}?",
"admin.blocks.deleteSuccess": "Block was deleted successfully.",
"admin.blocks.import": "Install Block...",
"admin.blocks.importFailed": "Failed to import the block package.",
"admin.blocks.importHint": "Install a block package (.wkblock) for this site.",
"admin.blocks.importSuccess": "{blockName} was imported successfully.",
"admin.blocks.importUpdated": "{blockName} was updated to the packaged version.",
"admin.blocks.isEnabled": "Enabled",
"admin.blocks.loadFailed": "Failed to load blocks.",
"admin.blocks.saveFailed": "Failed to save the blocks state.",

@ -79,6 +79,7 @@ export const AUDIT_ACTIONS = {
'updateAuthStrategy',
'deleteAuthStrategy',
'updateBlock',
'importBlock',
'deleteBlock',
'createGroup',
'updateGroup',

@ -1,7 +1,10 @@
import { readdir, readFile, stat } from 'node:fs/promises'
import crypto from 'node:crypto'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { and, eq, inArray } from 'drizzle-orm'
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
import { readBlockPackage } from '../helpers/wkblock.ts'
/** One authorable attribute of a block, as its `static definition` describes it. */
export interface BlockProp {
@ -90,6 +93,34 @@ export interface SiteBlock {
isChild: boolean
}
/**
* What a block is imported from, and what came of it. The reply to an import.
*/
export interface BlockImportResult {
id: string
block: string
name: string
/** False when the package replaced a block this site already had — an upgrade rather than a new one. */
isNew: boolean
fileCount: number
/** When the package was built, and by which version of the wiki. Empty if it did not say. */
packagedAt: string
packagedWith: string
}
/** Everything `postProcess` needs from this model to decide which block tags survive a save. */
export interface RenderableBlocks {
/** The keys of the blocks this site has switched on. */
enabled: Set<string>
/**
* The definitions of this site's CUSTOM blocks, which are on nobody's disk to be read from.
*
* Read in the same query as the keys above rather than from a cache, for the same reason that
* query is not cached: a definition this misses is a block stripped out of somebody's page.
*/
custom: BlockDefinition[]
}
const blockSelection = {
id: blocksTable.id,
block: blocksTable.block,
@ -98,7 +129,8 @@ const blockSelection = {
icon: blocksTable.icon,
isEnabled: blocksTable.isEnabled,
isCustom: blocksTable.isCustom,
config: blocksTable.config
config: blocksTable.config,
definition: blocksTable.definition
}
/**
@ -108,11 +140,37 @@ const blockSelection = {
* declared as a `static definition` on each Lit component and collected into
* `blocks/compiled/blocks.manifest.json` by the rollup build, which is what this model reads —
* the components themselves cannot be imported outside a browser.
*
* A CUSTOM block is the same thing built outside this tree: one block compiled on its own by
* `blocks/package.mjs` into a `.wkblock` file, uploaded here, and kept in the database. It has no
* manifest entry and never gets one — its definition is stored on its row, and its compiled files are
* unpacked into `<dataPath>/cache/blocks` the first time a browser asks for one. See `importPackage`.
*/
class Blocks {
/** Definitions read from the compiled manifest, refreshed by `refreshFromDisk()`. */
definitions: BlockDefinition[] = []
/**
* Which blocks are custom, per site, and at what version — `siteId` → `block` → checksum.
*
* This is what `controllers/blocks.ts` answers a request from, so it has to be reachable without a
* query: every file of every block on every page goes through it. Filled by `refreshCustomIndex()`
* at boot and after an import or a delete, and re-read on the `reloadBlocks` event so that the
* other instances of an HA set find out.
*/
private customIndex = new Map<string, Map<string, string>>()
/**
* The checksum this instance has already unpacked into the cache, keyed `siteId:block`.
*
* Just so a warm instance does not stat the cache on every request. The disk is still the authority
* — this only ever says "no need to look".
*/
private materialized = new Map<string, string>()
/** In-flight unpacks, so that a burst of requests for a cold block does not unpack it many times. */
private materializing = new Map<string, Promise<void>>()
/**
* Whether the last read of the manifest succeeded.
*
@ -302,22 +360,37 @@ class Blocks {
}
/**
* Fetch the blocks available to a site, built-in first, then by name
* Fetch the blocks available to a site, by name.
*
* By name ALONE, rather than built-in blocks and then imported ones: both screens that show this
* list — the admin area's and the editor's picker — are read to find one block in it, and a reader
* looking for "Greeter" should not have to know where it came from first. Which of the two a block is
* is on its own row either way.
*
* The child blocks follow at the end, since they are appended below rather than selected. Nothing
* displays one, so they have no order to be in.
*/
async getSiteBlocks(siteId: string): Promise<SiteBlock[]> {
const results = await WIKI.db
.select(blockSelection)
.from(blocksTable)
.where(eq(blocksTable.siteId, siteId))
.orderBy(blocksTable.isCustom, blocksTable.name)
.orderBy(blocksTable.name)
/*
`props` come from the manifest rather than the row: they describe the component's own attributes,
so they belong to the installed code and not to a site's copy of it. Reading them here means an
updated block's props are correct the moment it is deployed, with nothing to migrate — and a
custom block, having no manifest entry, simply reports none.
For a built-in, `props` come from the manifest rather than the row: they describe the component's
own attributes, so they belong to the installed code and not to a site's copy of it. Reading them
here means an updated block's props are correct the moment it is deployed, with nothing to
migrate.
A custom block has no manifest entry, and its row is where the installed code IS — the definition
was stored from its package at import, and is replaced whole whenever a newer package is
uploaded. Same rule, therefore, read from the other place.
*/
const listed = (results as SiteBlock[]).map((row) => {
const definition = this.definitions.find((d) => d.block === row.block)
const listed = (results as (SiteBlock & { definition: BlockDefinition })[]).map((stored) => {
const { definition: storedDefinition, ...row } = stored
const definition = row.isCustom
? storedDefinition
: this.definitions.find((d) => d.block === row.block)
return {
...row,
props: definition?.props ?? [],
@ -361,22 +434,34 @@ class Blocks {
}
/**
* The keys of the blocks a site has switched on.
* The blocks a site has switched on, and what the custom ones among them declare.
*
* Read from the database on every call rather than kept in a cache like this model's definitions.
* What this answer gates is which blocks survive a page being saved, and a stale `false` silently
* strips an author's block out of their page — a wrong answer here destroys content rather than
* merely showing the wrong list. One indexed read of a handful of rows, on a path that has just
* sanitised a whole document, is not worth that risk.
* sanitised a whole document, is not worth that risk. The custom definitions ride along in the same
* query for exactly the same reason: a block imported a minute ago on another instance of an HA set
* must not be stripped out of the first page saved after it.
*
* Child blocks never appear: they have no row of their own, and follow the block they sit in.
*/
async getEnabledKeys(siteId: string): Promise<Set<string>> {
async getEnabledForRender(siteId: string): Promise<RenderableBlocks> {
const rows = await WIKI.db
.select({ block: blocksTable.block })
.select({
block: blocksTable.block,
isCustom: blocksTable.isCustom,
definition: blocksTable.definition
})
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isEnabled, true)))
return new Set(rows.map((row) => row.block))
return {
enabled: new Set(rows.map((row) => row.block)),
custom: rows
.filter((row) => row.isCustom)
.map((row) => row.definition as BlockDefinition)
.filter((definition) => definition?.block)
}
}
/**
@ -410,12 +495,229 @@ class Blocks {
* @returns Whether a block was deleted
*/
async deleteCustomBlock(siteId: string, id: string): Promise<boolean> {
const result = await WIKI.db
const [deleted] = await WIKI.db
.delete(blocksTable)
.where(
and(eq(blocksTable.siteId, siteId), eq(blocksTable.id, id), eq(blocksTable.isCustom, true))
)
return (result.rowCount ?? 0) > 0
.returning({ block: blocksTable.block })
if (!deleted) {
return false
}
await this.discardCached(siteId, deleted.block)
await this.refreshCustomIndex()
WIKI.events.outbound.emit('reloadBlocks')
return true
}
// == CUSTOM BLOCKS ==================
/** Where unpacked custom blocks live. Derived and disposable — the packages are in the database. */
get cachePath(): string {
return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/blocks')
}
/**
* Install a packaged block on a site, or replace the one already there.
*
* Replacing is how a block is upgraded, and is why the row is updated rather than swapped: whether
* the site has the block switched on, and whatever it has configured on it, are the site's own
* answers and survive a new package. What the package brings is the code, the definition and the
* name — the three things that describe the block itself.
*
* The key is the identity. A package whose key is that of a built-in block is refused rather than
* shadowing it: the two would be served from the same URL, and one of them would silently win.
*/
async importPackage(siteId: string, data: Buffer): Promise<BlockImportResult> {
const pkg = readBlockPackage(data)
if (this.definitions.some((definition) => definition.block === pkg.block)) {
throw new CustomError(
'blockPackageConflict',
`This wiki already has a built-in block called "${pkg.block}", and both would be served from the same address. Rename the block and package it again.`,
409
)
}
const [existing] = await WIKI.db
.select({ id: blocksTable.id, isCustom: blocksTable.isCustom })
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, pkg.block)))
if (existing && !existing.isCustom) {
throw new CustomError(
'blockPackageConflict',
`This site already has a built-in block called "${pkg.block}".`,
409
)
}
const checksum = crypto.createHash('sha256').update(data).digest('hex')
const values = {
name: pkg.definition.name,
description: pkg.definition.description,
icon: pkg.definition.icon,
definition: pkg.definition,
packageData: data,
checksum
}
let id: string
if (existing) {
await WIKI.db.update(blocksTable).set(values).where(eq(blocksTable.id, existing.id))
id = existing.id
} else {
const [inserted] = await WIKI.db
.insert(blocksTable)
.values({
...values,
siteId,
block: pkg.block,
isEnabled: true,
isCustom: true,
config: {}
})
.returning({ id: blocksTable.id })
id = inserted.id
}
/*
The files are not written here. `materialize` unpacks them from the row on the first request, so
an upgrade lands on every instance of an HA set on its own — including one that was not running
when the upload happened. All this has to do is make sure nothing stale is left behind on THIS
instance, and tell the others to re-read the index.
*/
await this.discardCached(siteId, pkg.block)
await this.refreshCustomIndex()
WIKI.events.outbound.emit('reloadBlocks')
WIKI.logger.info(`Imported block ${pkg.block} (${pkg.files.size} file(s)) into site ${siteId}.`)
return {
id,
block: pkg.block,
name: pkg.definition.name,
isNew: !existing,
fileCount: pkg.files.size,
packagedAt: pkg.packagedAt,
packagedWith: pkg.packagedWith
}
}
/**
* Re-read which blocks are custom, on every site.
*
* Cheap — three small columns and no package bytes — and it is the only thing the serving route
* consults per request.
*/
async refreshCustomIndex(): Promise<void> {
const rows = await WIKI.db
.select({
siteId: blocksTable.siteId,
block: blocksTable.block,
checksum: blocksTable.checksum
})
.from(blocksTable)
.where(eq(blocksTable.isCustom, true))
const index = new Map<string, Map<string, string>>()
for (const row of rows) {
const forSite = index.get(row.siteId) ?? new Map<string, string>()
forSite.set(row.block, row.checksum)
index.set(row.siteId, forSite)
}
this.customIndex = index
}
/**
* The directory a custom block's files are served from, unpacking them first if need be.
*
* Null when this site has no custom block by that key, which is the ordinary answer — every request
* for a built-in block asks this first.
*/
async servingPathFor(siteId: string, block: string): Promise<string | null> {
const checksum = this.customIndex.get(siteId)?.get(block)
if (!checksum) {
return null
}
const key = `${siteId}:${block}`
if (this.materialized.get(key) !== checksum) {
let pending = this.materializing.get(key)
if (!pending) {
pending = this.materialize(siteId, block, checksum).finally(() => {
this.materializing.delete(key)
})
this.materializing.set(key, pending)
}
await pending
}
return path.join(this.cachePath, siteId, `block-${block}`)
}
/**
* Unpack a custom block's package into the cache, unless it is already there at this version.
*
* The marker file beside the directory is what says which version that is, and it is written last —
* so an unpack that died halfway leaves no marker, and the next request does it again rather than
* serving half a block. The directory is built under a temporary name and moved into place for the
* same reason: a request arriving mid-unpack sees the previous version or nothing, never a mixture.
*/
private async materialize(siteId: string, block: string, checksum: string): Promise<void> {
const siteDir = path.join(this.cachePath, siteId)
const blockDir = path.join(siteDir, `block-${block}`)
const markerPath = `${blockDir}.checksum`
const key = `${siteId}:${block}`
if (
await readFile(markerPath, 'utf8').then(
(value) => value === checksum,
() => false
)
) {
this.materialized.set(key, checksum)
return
}
const [row] = await WIKI.db
.select({ packageData: blocksTable.packageData })
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, block)))
if (!row?.packageData) {
throw new Error(`Block ${block} has no stored package to unpack.`)
}
const pkg = readBlockPackage(Buffer.from(row.packageData))
const stagingDir = `${blockDir}.${crypto.randomBytes(6).toString('hex')}`
try {
for (const [filePath, bytes] of pkg.files) {
const target = path.join(stagingDir, filePath)
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, bytes)
}
await rm(blockDir, { recursive: true, force: true })
await rename(stagingDir, blockDir)
await writeFile(markerPath, checksum, 'utf8')
} catch (err) {
await rm(stagingDir, { recursive: true, force: true })
throw err
}
this.materialized.set(key, checksum)
WIKI.logger.debug(`Unpacked block ${block} for site ${siteId} into the block cache.`)
}
/** Throw away one block's unpacked files, so that the next request writes them again. */
private async discardCached(siteId: string, block: string): Promise<void> {
const blockDir = path.join(this.cachePath, siteId, `block-${block}`)
this.materialized.delete(`${siteId}:${block}`)
await rm(`${blockDir}.checksum`, { force: true })
await rm(blockDir, { recursive: true, force: true })
}
/**
* Throw away every unpacked block, for `flushCaches`.
*
* Nothing is lost: the packages are rows, and the next request for each block writes its files back.
*/
async purgeCache(): Promise<void> {
this.materialized.clear()
await rm(this.cachePath, { recursive: true, force: true })
}
}

@ -6,6 +6,7 @@ import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/sc
import { CustomError } from '../helpers/common.ts'
import { hrefsFrom } from '../helpers/pageLinks.ts'
import type { IconifyIcon } from '@iconify/types'
import type { RenderableBlocks } from './blocks.ts'
import type { IconifyIconCustomisations } from '@iconify/utils'
/**
@ -404,8 +405,8 @@ class Rendering {
html: string,
permissions: RenderPermissions
): Promise<PostProcessResult> {
const enabledBlocks = await WIKI.models.blocks.getEnabledKeys(siteId)
const clean = this.sanitize(html ?? '', permissions, enabledBlocks)
const siteBlocks = await WIKI.models.blocks.getEnabledForRender(siteId)
const clean = this.sanitize(html ?? '', permissions, siteBlocks)
const $ = cheerio.load(clean, null, false)
@ -429,11 +430,12 @@ class Rendering {
* The block elements a page may carry, and what each of them may be given.
*
* A block is the one thing in a page that is not HTML, so sanitising against a list of HTML tags
* drops every one of them and no block ever survives being saved. The list is built from the
* compiled manifest — a block that is installed may be embedded, one that is not may not — and
* each tag gets exactly the attributes its component declares as props, which is the same set the
* editor's block picker offers. The markup is inert either way: what makes a block do anything is
* the component fetched from `/_blocks` at view time.
* drops every one of them and no block ever survives being saved. The list is built from what is
* installed — the compiled manifest, plus the definitions of the custom blocks this site has
* imported, which are on no disk to be read from — and each tag gets exactly the attributes its
* component declares as props, which is the same set the editor's block picker offers. The markup
* is inert either way: what makes a block do anything is the component fetched from `/_blocks` at
* view time.
*
* Installed is not sufficient: the block also has to be switched on for this site. Leaving the
* picker to decide that would only cover the authors who use it — the content is markdown, so
@ -445,14 +447,14 @@ class Rendering {
* Child blocks are exempt, having no switch of their own: a tab is part of the tabs it sits in,
* and is gated by `unwrapOrphanedChildBlocks` once the parent's fate is known.
*/
private blockAllowances(enabledBlocks: Set<string>): {
private blockAllowances(siteBlocks: RenderableBlocks): {
tags: string[]
attributes: Record<string, string[]>
} {
const tags: string[] = []
const attributes: Record<string, string[]> = {}
for (const definition of WIKI.models.blocks.definitions) {
if (!definition.isChild && !enabledBlocks.has(definition.block)) {
for (const definition of [...WIKI.models.blocks.definitions, ...siteBlocks.custom]) {
if (!definition.isChild && !siteBlocks.enabled.has(definition.block)) {
continue
}
const tag = `block-${definition.block}`
@ -517,9 +519,9 @@ class Rendering {
private sanitize(
html: string,
permissions: RenderPermissions,
enabledBlocks: Set<string>
siteBlocks: RenderableBlocks
): string {
const blocks = this.blockAllowances(enabledBlocks)
const blocks = this.blockAllowances(siteBlocks)
const allowedTags = [...BASE_ALLOWED_TAGS, ...blocks.tags]
const allowedAttributes: Record<string, string[]> = {
...BASE_ALLOWED_ATTRIBUTES,

2
blocks/.gitignore vendored

@ -1,3 +1,5 @@
compiled
dist
node_modules
packages
.package

@ -6,6 +6,7 @@
"type": "module",
"scripts": {
"build": "rollup -c",
"package": "node package.mjs",
"ncu": "ncu -i",
"ncu-u": "ncu -u"
},

@ -0,0 +1,206 @@
/**
* Package one block into a single `.wkblock` file, for importing into a Wiki.js instance.
*
* npm run package -- block-xyz
*
* The block is compiled on its own — see `buildConfig({ only })` in `rollup.config.mjs` for why that
* is not simply a slice of the normal build — and everything the compile emitted goes into the
* package, together with the `static definition` read off the component. The result lands in
* `packages/block-xyz.wkblock` and is uploaded from the instance's Administration → Content Blocks.
*
* ## The container
*
* `backend/helpers/wkblock.ts` is the other half of this and the two have to agree. There is no
* module to share between them: `blocks/` and `backend/` are separately installed workspaces and the
* backend does not type-check JavaScript, so the format is written twice and stated in full in both
* places.
*
* magic 8 bytes "WKBLOCK\0"
* version uint32be format version, 1
* headerLen uint32be byte length of the header that follows
* header gzip'd JSON — see below
* payload each file's gzip'd bytes, concatenated in the header's order
*
* The header:
*
* {
* "block": "xyz", // the key, i.e. the <block-xyz> element's suffix
* "definition": { ... }, // the component's `static definition`, verbatim
* "packagedAt": "2026-09-19T...Z",
* "packagedWith": "3.0.0", // the wiki the packager came from, for diagnostics only
* "files": [
* { "path": "block-xyz.js", "size": 12345, "compressedSize": 4321, "sha256": "..." }
* ]
* }
*
* Per-file gzip rather than one stream over the lot: a block's assets are often already-compressed
* images and fonts sitting beside a bundle that compresses four to one, and a file at a time means
* the reader can check a digest as it goes rather than after holding the whole package twice.
*
* Every path is relative to where the block is served from, and the reader refuses anything outside
* the block's own namespace — `block-<key>.js`, `block-<key>.worker.js` and `block-<key>/**`. That
* namespace is the whole of what keeps an imported block from overwriting a built-in one, so it is
* checked here as well, where the author can still do something about it.
*/
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import { rollup } from 'rollup'
import { buildConfig } from './rollup.config.mjs'
const MAGIC = Buffer.from('WKBLOCK\0', 'latin1')
const FORMAT_VERSION = 1
const STAGING_DIR = '.package'
const OUTPUT_DIR = 'packages'
/** Emitted by the manifest plugin for the server to read; the package carries the definition itself. */
const MANIFEST_FILE = 'blocks.manifest.json'
function fail (message) {
console.error(`\n ✖ ${message}\n`)
process.exit(1)
}
/**
* The block directory named on the command line, as `block-<key>`.
*
* Both spellings are taken, since half of what is on screen while working on a block says one and
* half says the other: the directory is `block-countdown` and the definition's key is `countdown`.
*/
function resolveBlockDir (argument) {
if (!argument) {
fail('Which block? Usage: npm run package -- block-xyz')
}
const dir = argument.replace(/\/+$/, '')
const candidate = dir.startsWith('block-') ? dir : `block-${dir}`
if (!/^block-[a-z0-9][a-z0-9-]*$/.test(candidate)) {
fail(`"${argument}" is not a block directory name — expected something like "block-xyz".`)
}
if (!fs.existsSync(path.join(candidate, 'component.js'))) {
fail(`${candidate}/component.js does not exist. A block is a directory under blocks/ with a component in it.`)
}
return candidate
}
/** Every file under a directory, as paths relative to it, in a stable order. */
function collectFiles (root) {
return fs.readdirSync(root, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile())
.map(entry => path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'))
.sort()
}
/**
* Refuse a compile that put something outside the block's own namespace.
*
* Nothing in the current build does — the config names chunks into `<block>/` and assets are copied
* there — so this is about a change to that config, or to a block's `assets.json`, quietly producing
* a package that stands on a file the importing instance already has.
*/
function assertNamespaced (files, blockDir) {
const allowed = [`${blockDir}.js`, `${blockDir}.worker.js`]
const stray = files.filter(file => !allowed.includes(file) && !file.startsWith(`${blockDir}/`))
if (stray.length > 0) {
fail(
`The compile emitted ${stray.length} file(s) outside ${blockDir}'s namespace:\n` +
stray.map(file => ` ${file}`).join('\n') +
`\n\n A package may only hold ${blockDir}.js, ${blockDir}.worker.js and ${blockDir}/**.`
)
}
}
function formatSize (bytes) {
return bytes < 1024 * 1024
? `${(bytes / 1024).toFixed(1)} kB`
: `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
async function main () {
const blockDir = resolveBlockDir(process.argv[2])
fs.rmSync(STAGING_DIR, { recursive: true, force: true })
console.log(`\n Compiling ${blockDir}...\n`)
const config = buildConfig({ only: blockDir, outputDir: STAGING_DIR })
const bundle = await rollup(config)
await bundle.write(config.output)
await bundle.close()
// -- The definition, which the manifest plugin has just read off the component's AST
const manifestPath = path.join(STAGING_DIR, MANIFEST_FILE)
const definitions = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
if (definitions.length !== 1) {
fail(`${blockDir}/component.js declares no "static definition" — there is nothing to package.`)
}
const definition = definitions[0]
if (`block-${definition.block}` !== blockDir) {
fail(
`${blockDir} declares itself as "${definition.block}", so it renders as <block-${definition.block}>.\n` +
` The directory name and the key have to match: a package is served as block-<key>.js.`
)
}
if (definition.isChild) {
fail(
`${blockDir} is a child block — it only ever appears inside another one, and has nothing to be\n` +
' installed or switched on separately from it. Package the block that holds it instead.'
)
}
// -- Everything the compile emitted, minus the manifest, which the package states for itself
const files = collectFiles(STAGING_DIR).filter(file => file !== MANIFEST_FILE)
assertNamespaced(files, blockDir)
if (!files.includes(`${blockDir}.js`)) {
fail(`The compile produced no ${blockDir}.js, which is the file the wiki loads the block from.`)
}
const entries = []
const payload = []
for (const file of files) {
const bytes = fs.readFileSync(path.join(STAGING_DIR, file))
const compressed = gzipSync(bytes, { level: 9 })
entries.push({
path: file,
size: bytes.length,
compressedSize: compressed.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex')
})
payload.push(compressed)
}
const header = gzipSync(Buffer.from(JSON.stringify({
block: definition.block,
definition,
packagedAt: new Date().toISOString(),
packagedWith: JSON.parse(fs.readFileSync('../backend/package.json', 'utf8')).version,
files: entries
}), 'utf8'), { level: 9 })
const preamble = Buffer.alloc(16)
MAGIC.copy(preamble, 0)
preamble.writeUInt32BE(FORMAT_VERSION, 8)
preamble.writeUInt32BE(header.length, 12)
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
const packagePath = path.join(OUTPUT_DIR, `${blockDir}.wkblock`)
fs.writeFileSync(packagePath, Buffer.concat([preamble, header, ...payload]))
fs.rmSync(STAGING_DIR, { recursive: true, force: true })
const uncompressed = entries.reduce((total, entry) => total + entry.size, 0)
console.log(` ${definition.name} — <block-${definition.block}>`)
console.log(` ${entries.length} file(s), ${formatSize(uncompressed)} uncompressed`)
console.log(`\n → ${packagePath} (${formatSize(fs.statSync(packagePath).size)})\n`)
console.log(' Import it from Administration → Content Blocks → Import Block.\n')
}
main().catch(err => {
console.error(err)
process.exit(1)
})

@ -111,8 +111,12 @@ function cssAsString () {
*
* The definitions are read from the AST rather than by importing the modules, since a component
* registers itself with `customElements` on load and so cannot be imported outside a browser.
*
* `only` narrows it to a single block directory, for `package.mjs` — a package carries the one
* definition the instance importing it will register, and nothing about the blocks that happened to
* be sitting beside it in the tree it was built from.
*/
function blocksManifest () {
function blocksManifest (only) {
const definitions = new Map()
return {
name: 'blocks-manifest',
@ -124,6 +128,9 @@ function blocksManifest () {
return null
}
const blockDir = id.split('/').at(-2)
if (only && blockDir !== only) {
return null
}
const ast = this.parse(code)
for (const node of ast.body) {
const classNode = node.type === 'ExportNamedDeclaration' ? node.declaration : node
@ -171,12 +178,14 @@ const IGNORED_DIRS = [
* starting with `./` for a directory of the block's own — mapped to the name it should have under
* `compiled/<block>/`. Everything below it is copied, so a block declares four directories rather
* than two hundred files.
*
* `only` narrows it to a single block directory, as above.
*/
function blockAssets () {
function blockAssets (only) {
return {
name: 'block-assets',
buildStart () {
for (const listPath of glob.sync('@(block-*)/assets.json', { ignore: IGNORED_DIRS })) {
for (const listPath of glob.sync(`@(${only ?? 'block-*'})/assets.json`, { ignore: IGNORED_DIRS })) {
const blockDir = listPath.split('/')[0]
this.addWatchFile(listPath)
const list = JSON.parse(fs.readFileSync(listPath, 'utf8'))
@ -211,51 +220,74 @@ function blockAssets () {
}
}
export default {
input: Object.fromEntries([
...glob.sync('@(block-*)/component.js', { ignore: IGNORED_DIRS }).map(file => {
const fileParts = file.split('/')
return [
fileParts[0],
file
]
}),
/*
A `worker.js` beside a component is a second entry point, compiled to `<block>.worker.js`.
/**
* The rollup configuration, for the whole `blocks/` tree or for one block of it.
*
* `only` is a block directory name (`block-xyz`), and is what `package.mjs` builds a distributable
* block with. Two things differ in that mode, both about the block ending up somewhere other than
* `compiled/` beside its siblings:
*
* - the output goes wherever the packager asks, since it is a staging directory rather than the
* tree the server serves;
* - shared chunks are named into `<block>/`, instead of sitting at the root of the output as they
* do here, where every block's chunks are named by one build and so cannot collide. A package is
* unpacked beside built-in blocks that were compiled separately and by a different version of
* the wiki, so a chunk at the root WOULD collide, and silently — two files of the same name,
* each some other bundle's half. Under the block's own directory there is nothing to collide
* with: the whole package is `block-<key>.js`, `block-<key>.worker.js` and `block-<key>/**`,
* which is the namespace the server hands back out.
*/
export function buildConfig ({ only, outputDir = 'compiled' } = {}) {
const entryGlob = only ?? 'block-*'
return {
input: Object.fromEntries([
...glob.sync(`@(${entryGlob})/component.js`, { ignore: IGNORED_DIRS }).map(file => {
const fileParts = file.split('/')
return [
fileParts[0],
file
]
}),
/*
A `worker.js` beside a component is a second entry point, compiled to `<block>.worker.js`.
A web worker is loaded by URL rather than imported, so its code cannot be part of the bundle
that starts it -- it has to be a file of its own, sitting in /_blocks where the block can point
at it with `new URL('<block>.worker.js', import.meta.url)`. See `block-pdf`, which runs pdf.js's
parser off the page's thread.
*/
...glob.sync('@(block-*)/worker.js', { ignore: IGNORED_DIRS }).map(file => {
const fileParts = file.split('/')
return [
`${fileParts[0]}.worker`,
file
]
})
]),
output: {
dir: 'compiled',
format: 'es'
},
plugins: [
blocksManifest(),
blockAssets(),
cssAsString(),
// -> `production` is stated rather than left to be inferred: since v16 the plugin picks the
// `development` or `production` export condition off `process.env.NODE_ENV`, and this build
// runs from a bare `npm run build` with no NODE_ENV set. Unstated, lit resolves to its
// development entry and every block ships the dev-mode warnings and asserts.
resolve({ exportConditions: ['production'] }),
// -> A block's own code is ESM, but a library it pulls in need not be: mermaid reaches for dayjs,
// which ships as UMD, and rollup has no notion of `module.exports` without this
commonjs(),
terser({
ecma: 2019,
module: true
}),
summary()
]
A web worker is loaded by URL rather than imported, so its code cannot be part of the bundle
that starts it -- it has to be a file of its own, sitting in /_blocks where the block can point
at it with `new URL('<block>.worker.js', import.meta.url)`. See `block-pdf`, which runs pdf.js's
parser off the page's thread.
*/
...glob.sync(`@(${entryGlob})/worker.js`, { ignore: IGNORED_DIRS }).map(file => {
const fileParts = file.split('/')
return [
`${fileParts[0]}.worker`,
file
]
})
]),
output: {
dir: outputDir,
format: 'es',
...(only ? { chunkFileNames: `${only}/[name]-[hash].js` } : {})
},
plugins: [
blocksManifest(only),
blockAssets(only),
cssAsString(),
// -> `production` is stated rather than left to be inferred: since v16 the plugin picks the
// `development` or `production` export condition off `process.env.NODE_ENV`, and this build
// runs from a bare `npm run build` with no NODE_ENV set. Unstated, lit resolves to its
// development entry and every block ships the dev-mode warnings and asserts.
resolve({ exportConditions: ['production'] }),
// -> A block's own code is ESM, but a library it pulls in need not be: mermaid reaches for dayjs,
// which ships as UMD, and rollup has no notion of `module.exports` without this
commonjs(),
terser({
ecma: 2019,
module: true
}),
summary()
]
}
}
export default buildConfig()

@ -5,22 +5,26 @@
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-plugin.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.blocks.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.blocks.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.blocks.subtitle') }}
</div>
</div>
<div class="flex-none flex">
<template v-if="flagsStore.experimental">
<w-btn
class="mr-2 acrylic-btn"
unelevated
icon="la:plus"
:label="t(`admin.blocks.add`)"
color="primary"
@click="addBlock" />
<w-separator class="mr-2" vertical />
</template>
<w-btn
class="mr-2 acrylic-btn"
flat
icon="la:file-upload"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`admin.blocks.import`)"
:loading="state.importing"
:disabled="state.loading > 0 || state.importing"
@click="pickPackage">
<w-tooltip>{{ t(`admin.blocks.importHint`) }}</w-tooltip>
</w-btn>
<w-separator class="mr-2" vertical />
<w-btn
class="mr-2 acrylic-btn"
icon="la:question-circle"
@ -99,12 +103,18 @@
</w-list>
</w-card>
</div>
<input
type="file"
ref="packageFileIpt"
accept=".wkblock"
style="display: none"
@change="importPackage" />
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
@ -113,7 +123,6 @@ import { loading } from '@/composables/loading'
import { confirm } from '@/composables/dialog'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { pick } from 'es-toolkit/object'
@ -126,7 +135,6 @@ const dark = useDark()
// STORES
const adminStore = useAdminStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
// I18N
@ -141,9 +149,12 @@ useMeta(() => ({
const state = reactive({
loading: 0,
importing: false,
blocks: []
})
const packageFileIpt = ref(null)
// WATCHERS
watch(
@ -208,13 +219,50 @@ async function refresh() {
await load()
}
function addBlock() {
// TODO: registering a custom block means uploading a compiled component, which needs an upload
// endpoint that does not exist yet. Built-in blocks come from the compiled block manifest.
notify({
type: 'warning',
message: t('admin.blocks.addUnavailable')
})
function pickPackage() {
packageFileIpt.value?.click()
}
/**
* Install a `.wkblock` — a block somebody built outside this instance and packaged into one file.
*
* The body is the file itself rather than a multipart form, as every other upload here is. A package
* whose block this site already has replaces it, which is how a custom block is upgraded, so the
* reply says which of the two happened.
*/
async function importPackage() {
const file = packageFileIpt.value?.files?.[0]
if (!file || state.importing) {
return
}
state.importing = true
try {
const resp = await API_CLIENT.post(`sites/${adminStore.currentSiteId}/blocks/import`, {
headers: { 'content-type': 'application/octet-stream' },
body: file
}).json()
// -> The API client does not throw on 400, which is what a package that is not one comes back as,
// so a refusal arrives here as a parsed error. A key a built-in block already has is a 409 and
// does throw — both paths have to be reported
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t(resp.isNew ? 'admin.blocks.importSuccess' : 'admin.blocks.importUpdated', {
blockName: resp.name
})
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.blocks.importFailed'),
caption: apiErrorMessage(err)
})
}
packageFileIpt.value.value = null
state.importing = false
}
function deleteBlock(id) {

Loading…
Cancel
Save