mirror of https://github.com/requarks/wiki
parent
40a0abc3bb
commit
4507d9f7ad
@ -0,0 +1,305 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/** Extensions a browser may render inline. Everything else is sent as a download. */
|
||||||
|
const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'])
|
||||||
|
|
||||||
|
const assetIdParam = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
assetId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['siteId', 'assetId']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets API Routes
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
// -> An upload is the raw file rather than a multipart form: one file per request, with the name and
|
||||||
|
// the destination in the query string. The catch-all only claims content types nothing else
|
||||||
|
// parses, so the JSON routes below are unaffected.
|
||||||
|
//
|
||||||
|
// The limit is read once, here, because a route's body limit is fixed when it is registered —
|
||||||
|
// changing it in the admin area takes effect on the next restart, as the rest of the security
|
||||||
|
// settings do.
|
||||||
|
app.addContentTypeParser(
|
||||||
|
'*',
|
||||||
|
{ parseAs: 'buffer', bodyLimit: WIKI.config.security?.uploadMaxFileSize ?? 10485760 },
|
||||||
|
(req, body, done) => {
|
||||||
|
done(null, body)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPLOAD ASSET
|
||||||
|
*/
|
||||||
|
app.post<{
|
||||||
|
Params: { siteId: string }
|
||||||
|
Querystring: { fileName: string; folderId?: string; locale?: string }
|
||||||
|
}>(
|
||||||
|
'/sites/:siteId/assets',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['write:assets', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Upload an asset',
|
||||||
|
description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.`,
|
||||||
|
tags: ['Assets'],
|
||||||
|
consumes: ['*/*'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['siteId']
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
fileName: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
folderId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The folder to upload into. The site root when absent.'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 10,
|
||||||
|
description: "The site's primary locale when absent."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['fileName']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Asset uploaded successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
asset: { $ref: 'Asset#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
// -> An asset records who uploaded it, and an API key is not a who
|
||||||
|
const authorId = req.session?.authenticated ? req.session.user?.id : null
|
||||||
|
if (!authorId) {
|
||||||
|
return reply.unauthorized('Uploading an asset requires a logged in user.')
|
||||||
|
}
|
||||||
|
const data = req.body
|
||||||
|
if (!Buffer.isBuffer(data) || data.length < 1) {
|
||||||
|
return reply.badRequest('No file was sent.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = await WIKI.models.assets.upload({
|
||||||
|
siteId: req.params.siteId,
|
||||||
|
locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en',
|
||||||
|
folderId: req.query.folderId,
|
||||||
|
fileName: req.query.fileName,
|
||||||
|
mimeType: req.headers['content-type'],
|
||||||
|
data,
|
||||||
|
authorId
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Asset uploaded successfully.',
|
||||||
|
asset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET ASSET
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string; assetId: string } }>(
|
||||||
|
'/sites/:siteId/assets/:assetId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['read:assets', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Get a single asset',
|
||||||
|
description: 'Metadata only. `/content` serves the file itself.',
|
||||||
|
tags: ['Assets'],
|
||||||
|
params: assetIdParam,
|
||||||
|
response: {
|
||||||
|
200: { $ref: 'Asset#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
|
||||||
|
if (!asset) {
|
||||||
|
return reply.notFound('This asset does not exist.')
|
||||||
|
}
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOWNLOAD ASSET
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string; assetId: string } }>(
|
||||||
|
'/sites/:siteId/assets/:assetId/content',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['read:assets', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Download an asset',
|
||||||
|
description:
|
||||||
|
'The file itself. Anything a browser should not render inline is sent as an attachment, and the type is always the one derived from the stored file name.',
|
||||||
|
tags: ['Assets'],
|
||||||
|
params: assetIdParam,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'The file',
|
||||||
|
content: {
|
||||||
|
'*/*': {
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
|
||||||
|
if (!asset) {
|
||||||
|
return reply.notFound('This asset does not exist.')
|
||||||
|
}
|
||||||
|
const content = await WIKI.models.assets.getContent(req.params.assetId)
|
||||||
|
if (!content) {
|
||||||
|
return reply.notFound('This asset has no content.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WIKI.config.security?.forceAssetDownload || !INLINE_EXTS.has(asset.fileExt)) {
|
||||||
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${encodeURIComponent(asset.fileName)}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// -> The bytes came from a user, so the browser must take the type at its word rather than
|
||||||
|
// looking for something more interesting in them
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
return reply.type(content.mimeType).send(content.data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RENAME ASSET
|
||||||
|
*/
|
||||||
|
app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>(
|
||||||
|
'/sites/:siteId/assets/:assetId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Rename an asset',
|
||||||
|
description:
|
||||||
|
'The extension is part of the name, and changing it changes the type the file is served as.',
|
||||||
|
tags: ['Assets'],
|
||||||
|
params: assetIdParam,
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['fileName'],
|
||||||
|
properties: {
|
||||||
|
fileName: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'Sanitized, so the stored name may differ from the one sent.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Asset renamed successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
asset: { $ref: 'Asset#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const asset = await WIKI.models.assets.renameAsset(
|
||||||
|
req.params.siteId,
|
||||||
|
req.params.assetId,
|
||||||
|
req.body.fileName
|
||||||
|
)
|
||||||
|
if (!asset) {
|
||||||
|
return reply.notFound('This asset does not exist.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Asset renamed successfully.',
|
||||||
|
asset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE ASSET
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { siteId: string; assetId: string } }>(
|
||||||
|
'/sites/:siteId/assets/:assetId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Delete an asset',
|
||||||
|
tags: ['Assets'],
|
||||||
|
params: assetIdParam,
|
||||||
|
response: {
|
||||||
|
204: {
|
||||||
|
description: 'Asset deleted successfully'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!(await WIKI.models.assets.deleteAsset(req.params.siteId, req.params.assetId))) {
|
||||||
|
return reply.notFound('This asset does not exist.')
|
||||||
|
}
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||||
|
import { NAVIGATION_MODES, type NavigationItem, type NavigationMode } from '../models/navigation.ts'
|
||||||
|
|
||||||
|
const navigationItem = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
type: { type: 'string', enum: ['link', 'header', 'separator'] },
|
||||||
|
label: { type: 'string' },
|
||||||
|
icon: { type: 'string' },
|
||||||
|
target: { type: 'string' },
|
||||||
|
openInNewWindow: { type: 'boolean' },
|
||||||
|
visibilityGroups: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Groups the item is limited to. Visible to everyone when empty.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the requester may see and edit a menu whole, rather than only the parts meant for them. */
|
||||||
|
function canManageNavigation(req: FastifyRequest): boolean {
|
||||||
|
const permissions = req.session?.authenticated ? (req.session.permissions ?? []) : []
|
||||||
|
return permissions.includes('manage:navigation') || permissions.includes('manage:system')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation API Routes
|
||||||
|
*
|
||||||
|
* A menu belongs to a tree entry that overrides it, or to the site itself for the one every page falls
|
||||||
|
* back to — both addressed by the same id, which is why there is a single route to read one.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* GET NAVIGATION
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string; navId: string }; Querystring: { full?: boolean } }>(
|
||||||
|
'/sites/:siteId/navigation/:navId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
summary: 'Get a navigation menu',
|
||||||
|
description:
|
||||||
|
"The items of one menu, addressed by the id a page's `navigationId` points at.\n\nReadable without a session, because the sidebar is drawn for anonymous readers too. Items limited to a group are dropped for anyone outside it, at both levels of the menu — so what comes back is what the requester may see, not the whole menu. `full` asks for the whole of it instead, and needs `manage:navigation`.",
|
||||||
|
tags: ['Navigation'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: { type: 'string', format: 'uuid' },
|
||||||
|
navId: { type: 'string', format: 'uuid' }
|
||||||
|
},
|
||||||
|
required: ['siteId', 'navId']
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
full: {
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description: 'Include items limited to groups the requester is not in.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'The menu items, in the order they are shown',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
...navigationItem,
|
||||||
|
properties: {
|
||||||
|
...navigationItem.properties,
|
||||||
|
children: { type: 'array', items: navigationItem }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const unfiltered = Boolean(req.query.full)
|
||||||
|
if (unfiltered && !canManageNavigation(req)) {
|
||||||
|
return reply.forbidden('Reading a menu in full requires the manage:navigation permission.')
|
||||||
|
}
|
||||||
|
return WIKI.models.navigation.getNav(req.params.navId, {
|
||||||
|
userGroups: req.session?.authenticated ? (req.session.groups ?? []) : [],
|
||||||
|
unfiltered
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE NAVIGATION
|
||||||
|
*/
|
||||||
|
app.put<{
|
||||||
|
Params: { siteId: string; pageId: string }
|
||||||
|
Body: { mode: NavigationMode; items?: NavigationItem[] }
|
||||||
|
}>(
|
||||||
|
'/sites/:siteId/navigation/pages/:pageId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:navigation']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Set how a page resolves its navigation',
|
||||||
|
description:
|
||||||
|
"Records the mode on the tree entry and repoints every descendant that still inherits, stopping at any that overrides or hides in between.\n\nSending `items` stores them as this entry's menu as well — for the home page that is the site-wide menu, which is what every other page inherits. Leaving `items` out changes only the mode.",
|
||||||
|
tags: ['Navigation'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: { type: 'string', format: 'uuid' },
|
||||||
|
pageId: { type: 'string', format: 'uuid' }
|
||||||
|
},
|
||||||
|
required: ['siteId', 'pageId']
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['mode'],
|
||||||
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: NAVIGATION_MODES
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
...navigationItem,
|
||||||
|
properties: {
|
||||||
|
...navigationItem.properties,
|
||||||
|
children: { type: 'array', items: navigationItem }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Navigation updated successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
navigationMode: { type: 'string' },
|
||||||
|
navigationId: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: 'The menu this page now resolves to. Null when the sidebar is hidden.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const result = await WIKI.models.navigation.updateNavigation({
|
||||||
|
siteId: req.params.siteId,
|
||||||
|
pageId: req.params.pageId,
|
||||||
|
mode: req.body.mode,
|
||||||
|
items: req.body.items
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Navigation updated successfully.',
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* ASSET - An uploaded file, without its contents
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'Asset',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
fileExt: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Lowercase, without the dot.'
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['document', 'image', 'other']
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'In bytes.'
|
||||||
|
},
|
||||||
|
folderPath: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Slash-separated, without a leading or trailing slash. Empty at the site root.'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
hasPreview: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether a thumbnail was generated, and `/_thumb/<id>.webp` will serve one.'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date that may not be set.
|
||||||
|
*
|
||||||
|
* An empty string counts as unset alongside null, because that is how the editor holds a date nobody
|
||||||
|
* has filled in — rejecting it would fail every save of a page that is not scheduled.
|
||||||
|
*/
|
||||||
|
const optionalDateTime = {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
{ type: 'string', maxLength: 0 },
|
||||||
|
{ type: 'null' }
|
||||||
|
],
|
||||||
|
description: 'Empty or null when there is no date.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* PAGE INPUT - The writable fields, used for both create and update
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'PageInput',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
pattern: '^/?[a-zA-Z0-9-_/]*$',
|
||||||
|
description: 'Where the page lives, without a leading slash. Lowercased when stored.'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
pattern: '^[a-zA-Z0-9-_]*$'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 10,
|
||||||
|
description: "The site's primary locale when absent."
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'Which editor authored the content, e.g. `markdown`.'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The source, in whatever the editor writes.'
|
||||||
|
},
|
||||||
|
render: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"The HTML the editor produced. Sanitized against the author's permissions before it is stored, and the table of contents and search text are derived from the result — so what comes back may differ from what was sent."
|
||||||
|
},
|
||||||
|
publishState: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['draft', 'published', 'scheduled']
|
||||||
|
},
|
||||||
|
publishStartDate: optionalDateTime,
|
||||||
|
publishEndDate: optionalDateTime,
|
||||||
|
isBrowsable: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
isSearchable: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
allowComments: { type: 'boolean' },
|
||||||
|
allowContributions: { type: 'boolean' },
|
||||||
|
allowRatings: { type: 'boolean' },
|
||||||
|
showSidebar: { type: 'boolean' },
|
||||||
|
showTags: { type: 'boolean' },
|
||||||
|
showToc: { type: 'boolean' },
|
||||||
|
tocDepth: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
min: { type: 'integer', minimum: 1, maximum: 6 },
|
||||||
|
max: { type: 'integer', minimum: 1, maximum: 6 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scriptJsLoad: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Requires the `write:scripts` permission. Ignored without it.'
|
||||||
|
},
|
||||||
|
scriptJsUnload: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Requires the `write:scripts` permission. Ignored without it.'
|
||||||
|
},
|
||||||
|
scriptCss: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Requires the `write:styles` permission. Ignored without it.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAGE - A page as it is served back
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'Page',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
path: { type: 'string' },
|
||||||
|
hash: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Hash of the path, which is how a page is addressed by URL.'
|
||||||
|
},
|
||||||
|
alias: { type: ['string', 'null'] },
|
||||||
|
title: { type: 'string' },
|
||||||
|
description: { type: ['string', 'null'] },
|
||||||
|
icon: { type: ['string', 'null'] },
|
||||||
|
locale: { type: 'string' },
|
||||||
|
editor: { type: 'string' },
|
||||||
|
contentType: { type: 'string' },
|
||||||
|
publishState: { type: 'string', enum: ['draft', 'published', 'scheduled'] },
|
||||||
|
publishStartDate: { type: ['string', 'null'], format: 'date-time' },
|
||||||
|
publishEndDate: { type: ['string', 'null'], format: 'date-time' },
|
||||||
|
isBrowsable: { type: 'boolean' },
|
||||||
|
isSearchable: { type: 'boolean' },
|
||||||
|
password: { type: ['string', 'null'] },
|
||||||
|
relations: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object', additionalProperties: true }
|
||||||
|
},
|
||||||
|
tags: { type: 'array', items: { type: 'string' } },
|
||||||
|
toc: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Nested headings, derived from the stored render.',
|
||||||
|
items: { type: 'object', additionalProperties: true }
|
||||||
|
},
|
||||||
|
render: { type: 'string' },
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Only present when the request asked for it.'
|
||||||
|
},
|
||||||
|
allowComments: { type: 'boolean' },
|
||||||
|
allowContributions: { type: 'boolean' },
|
||||||
|
allowRatings: { type: 'boolean' },
|
||||||
|
showSidebar: { type: 'boolean' },
|
||||||
|
showTags: { type: 'boolean' },
|
||||||
|
showToc: { type: 'boolean' },
|
||||||
|
tocDepth: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
min: { type: 'integer' },
|
||||||
|
max: { type: 'integer' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scriptJsLoad: { type: 'string' },
|
||||||
|
scriptJsUnload: { type: 'string' },
|
||||||
|
scriptCss: { type: 'string' },
|
||||||
|
navigationId: { type: ['string', 'null'] },
|
||||||
|
navigationMode: { type: 'string' },
|
||||||
|
authorId: { type: 'string', format: 'uuid' },
|
||||||
|
authorName: { type: 'string' },
|
||||||
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
updatedAt: { type: 'string', format: 'date-time' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* TREE ITEM - One entry of a folder listing, whichever of the three kinds it is
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'TreeItem',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['folder', 'page', 'asset']
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'How many folders deep the entry sits, 0 being the site root.'
|
||||||
|
},
|
||||||
|
folderPath: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Slash-separated, without a leading or trailing slash. Empty at the site root.'
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
childrenCount: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Folders only — how many entries the folder holds.'
|
||||||
|
},
|
||||||
|
isAncestor: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Folders only — true when the folder sits above the one being listed, i.e. it came from `includeAncestors` or `includeRootFolders` rather than from the listing itself.'
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Assets only — in bytes.'
|
||||||
|
},
|
||||||
|
fileExt: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Assets only — lowercase, without the dot.'
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Assets only.'
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Pages only.'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Pages only.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FOLDER INPUT - The writable fields of a folder, used for both create and rename
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'FolderInput',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pathName: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255,
|
||||||
|
pattern: '^[a-z0-9-]+$',
|
||||||
|
description: "The folder's own path segment, as it appears in a URL."
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'What the folder is called when it is shown to a reader.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FOLDER - A folder, as returned after creating or renaming one
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'Folder',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
folderPath: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Slash-separated path of the folder holding this one. Empty at the site root.'
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
childrenCount: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag API Routes
|
||||||
|
*
|
||||||
|
* Tags are derived from the pages that carry them rather than stored on their own — see
|
||||||
|
* `models/tags.ts` for why.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* LIST TAGS
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string }; Querystring: { limit?: number } }>(
|
||||||
|
'/sites/:siteId/tags',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['read:pages', 'write:pages', 'manage:pages']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List the tags in use on a site',
|
||||||
|
description:
|
||||||
|
'Every tag carried by at least one page, most used first. This is what the tag field offers as suggestions while a page is being edited.',
|
||||||
|
tags: ['Pages'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['siteId']
|
||||||
|
},
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 5000,
|
||||||
|
default: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Tags in use, most used first',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tag: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
usageCount: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'How many pages carry the tag.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
return WIKI.models.tags.getTags(req.params.siteId, { limit: req.query.limit })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,390 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts'
|
||||||
|
import { decodeTreePath } from '../helpers/common.ts'
|
||||||
|
|
||||||
|
interface TreeQuery {
|
||||||
|
parentId?: string
|
||||||
|
parentPath?: string
|
||||||
|
locale?: string
|
||||||
|
types?: string
|
||||||
|
tags?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
orderBy?: TreeOrderBy
|
||||||
|
orderByDirection?: 'asc' | 'desc'
|
||||||
|
depth?: number
|
||||||
|
includeAncestors?: boolean
|
||||||
|
includeRootFolders?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderBody {
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
pathName: string
|
||||||
|
title: string
|
||||||
|
locale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale content belongs to when the request does not say.
|
||||||
|
*
|
||||||
|
* A site always has a primary locale, and an instance that never turned locales on has exactly that
|
||||||
|
* one — so this is the answer for most requests rather than a fallback.
|
||||||
|
*/
|
||||||
|
function defaultLocale(siteId: string): string {
|
||||||
|
return WIKI.sites[siteId]?.config?.locales?.primary ?? 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
|
||||||
|
function splitList(value?: string): string[] | null {
|
||||||
|
const items = value
|
||||||
|
?.split(',')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return items && items.length > 0 ? items : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteIdParam = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['siteId']
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIdParam = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
folderId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['siteId', 'folderId']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree API Routes
|
||||||
|
*
|
||||||
|
* The tree is what the file manager and the navigation browse: one listing that interleaves folders,
|
||||||
|
* pages and assets. Folders are the only kind created here — a page or an asset gets its tree entry
|
||||||
|
* from whatever created it.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* BROWSE THE TREE
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string }; Querystring: TreeQuery }>(
|
||||||
|
'/sites/:siteId/tree',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['read:pages', 'read:assets', 'manage:pages', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Browse the tree',
|
||||||
|
description:
|
||||||
|
'Lists the contents of one folder. `parentId` and `parentPath` both address the folder to list, the ID winning when both are given; neither means the site root. `includeAncestors` and `includeRootFolders` add the folders above the one being listed, so that a client opening a deep folder can draw the whole branch from a single request — those entries come back with `isAncestor` set.',
|
||||||
|
tags: ['Tree'],
|
||||||
|
params: siteIdParam,
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
parentId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
parentPath: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 2048,
|
||||||
|
description: 'Slash-separated path of the folder to list.'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 10,
|
||||||
|
description: 'Only entries in this locale. Every locale when absent.'
|
||||||
|
},
|
||||||
|
types: {
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^(folder|page|asset)(,(folder|page|asset))*$',
|
||||||
|
description: 'Comma-separated list of kinds to include, e.g. `folder,page`.'
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comma-separated list of tags an entry must carry all of.'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 1000,
|
||||||
|
default: 1000
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
type: 'string',
|
||||||
|
enum: TREE_ORDER_BY,
|
||||||
|
default: 'title'
|
||||||
|
},
|
||||||
|
orderByDirection: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['asc', 'desc'],
|
||||||
|
default: 'asc'
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 10,
|
||||||
|
default: 0,
|
||||||
|
description: 'How many levels below the folder to include. 0 is the folder itself.'
|
||||||
|
},
|
||||||
|
includeAncestors: {
|
||||||
|
type: 'boolean',
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
includeRootFolders: {
|
||||||
|
type: 'boolean',
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Tree entries, shallowest first',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'TreeItem#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const q = req.query
|
||||||
|
return WIKI.models.tree.getTree({
|
||||||
|
siteId: req.params.siteId,
|
||||||
|
parentId: q.parentId,
|
||||||
|
parentPath: q.parentPath,
|
||||||
|
locale: q.locale,
|
||||||
|
types: splitList(q.types) as TreeItemType[] | null,
|
||||||
|
tags: splitList(q.tags),
|
||||||
|
limit: q.limit,
|
||||||
|
offset: q.offset,
|
||||||
|
orderBy: q.orderBy,
|
||||||
|
orderByDirection: q.orderByDirection,
|
||||||
|
depth: q.depth,
|
||||||
|
includeAncestors: q.includeAncestors,
|
||||||
|
includeRootFolders: q.includeRootFolders
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET FOLDER
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string; folderId: string } }>(
|
||||||
|
'/sites/:siteId/tree/folders/:folderId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['read:pages', 'read:assets', 'manage:pages', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Get a single folder',
|
||||||
|
tags: ['Tree'],
|
||||||
|
params: folderIdParam,
|
||||||
|
response: {
|
||||||
|
200: { $ref: 'Folder#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const folder = await WIKI.models.tree.getFolderById(req.params.folderId)
|
||||||
|
if (!folder || folder.siteId !== req.params.siteId) {
|
||||||
|
return reply.notFound('This folder does not exist.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
|
||||||
|
childrenCount: folder.meta?.children ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE FOLDER
|
||||||
|
*/
|
||||||
|
app.post<{ Params: { siteId: string }; Body: FolderBody }>(
|
||||||
|
'/sites/:siteId/tree/folders',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['write:pages', 'write:assets', 'manage:pages', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Create a folder',
|
||||||
|
description:
|
||||||
|
'Any folder missing between the site root and the new one is created along with it, so a path can be filled in from the middle out.',
|
||||||
|
tags: ['Tree'],
|
||||||
|
params: siteIdParam,
|
||||||
|
body: {
|
||||||
|
allOf: [
|
||||||
|
{ $ref: 'FolderInput#' },
|
||||||
|
{ required: ['pathName', 'title'] },
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
parentId: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The folder to create it in. Wins over `parentPath`.'
|
||||||
|
},
|
||||||
|
parentPath: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
maxLength: 2048,
|
||||||
|
description: 'Slash-separated path of the folder to create it in.'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 10,
|
||||||
|
description: "The site's primary locale when absent."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Folder created successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
folder: { $ref: 'Folder#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const folder = await WIKI.models.tree.createFolder({
|
||||||
|
siteId: req.params.siteId,
|
||||||
|
locale: req.body.locale ?? defaultLocale(req.params.siteId),
|
||||||
|
parentId: req.body.parentId,
|
||||||
|
parentPath: req.body.parentPath,
|
||||||
|
pathName: req.body.pathName,
|
||||||
|
title: req.body.title
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Folder created successfully.',
|
||||||
|
folder: {
|
||||||
|
...folder,
|
||||||
|
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
|
||||||
|
childrenCount: folder.meta?.children ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RENAME FOLDER
|
||||||
|
*/
|
||||||
|
app.patch<{ Params: { siteId: string; folderId: string }; Body: FolderBody }>(
|
||||||
|
'/sites/:siteId/tree/folders/:folderId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:pages', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Rename a folder',
|
||||||
|
description:
|
||||||
|
'Everything under the folder moves with it. Sending the current path name back changes only the title, and leaves every descendant untouched.',
|
||||||
|
tags: ['Tree'],
|
||||||
|
params: folderIdParam,
|
||||||
|
body: {
|
||||||
|
allOf: [{ $ref: 'FolderInput#' }, { required: ['pathName', 'title'] }]
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Folder renamed successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
folder: { $ref: 'Folder#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = await WIKI.models.tree.getFolderById(req.params.folderId)
|
||||||
|
if (!existing || existing.siteId !== req.params.siteId) {
|
||||||
|
return reply.notFound('This folder does not exist.')
|
||||||
|
}
|
||||||
|
const folder = await WIKI.models.tree.renameFolder({
|
||||||
|
folderId: req.params.folderId,
|
||||||
|
pathName: req.body.pathName,
|
||||||
|
title: req.body.title
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Folder renamed successfully.',
|
||||||
|
folder: {
|
||||||
|
...folder,
|
||||||
|
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
|
||||||
|
childrenCount: folder.meta?.children ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE FOLDER
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { siteId: string; folderId: string } }>(
|
||||||
|
'/sites/:siteId/tree/folders/:folderId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:pages', 'manage:assets']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Delete a folder',
|
||||||
|
description:
|
||||||
|
'Everything under the folder goes with it, assets included. Pages are not implemented yet, so their tree entries are removed but nothing else is.',
|
||||||
|
tags: ['Tree'],
|
||||||
|
params: folderIdParam,
|
||||||
|
response: {
|
||||||
|
204: {
|
||||||
|
description: 'Folder deleted successfully'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = await WIKI.models.tree.getFolderById(req.params.folderId)
|
||||||
|
if (!existing || existing.siteId !== req.params.siteId) {
|
||||||
|
return reply.notFound('This folder does not exist.')
|
||||||
|
}
|
||||||
|
const removed = await WIKI.models.tree.deleteFolder(req.params.folderId)
|
||||||
|
await WIKI.models.assets.deleteOrphaned(removed.assets)
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page a headless browser loads in order to render markdown for the server.
|
||||||
|
*
|
||||||
|
* Nothing but a host for the frontend's renderer bundle — it holds no data, reads nothing and
|
||||||
|
* displays nothing. `models/rendering.ts` navigates here, waits for `__wikiRenderReady` and calls
|
||||||
|
* `__wikiRender` with the content to render.
|
||||||
|
*/
|
||||||
|
const SHELL = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Wiki.js Renderer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="/_assets/renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _render Routes
|
||||||
|
*
|
||||||
|
* Only ever fetched over the loopback interface by this instance's own headless browser, but served
|
||||||
|
* like the other static shells rather than gated: there is nothing here to protect, and a session the
|
||||||
|
* browser does not have could not be checked anyway.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
app.get('/', async (_req, reply) => {
|
||||||
|
// -> The bundle it pulls in is hashed and immutable, but this page must not be, or a rebuilt
|
||||||
|
// frontend would keep rendering through the previous one
|
||||||
|
reply.header('Cache-Control', 'no-store')
|
||||||
|
return reply.type('text/html; charset=utf-8').send(SHELL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { validate as uuidValidate } from 'uuid'
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thumbnail is generated once, at upload time, and an asset that changes gets a new ID — so the
|
||||||
|
* bytes behind a given URL never change.
|
||||||
|
*/
|
||||||
|
const THUMB_CACHE = 'public, max-age=31536000, immutable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* _thumb Routes
|
||||||
|
*
|
||||||
|
* Public, like `_site` and `_user`: a thumbnail is a shrunken copy of an asset already served on the
|
||||||
|
* pages that embed it, and the URL has to be known to be asked for. Only assets that have a preview
|
||||||
|
* answer here — everything else, including every non-image, is a 404 the file manager draws a file
|
||||||
|
* type icon for.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
app.get<{ Params: { fileName: string } }>('/:fileName', async (req, reply) => {
|
||||||
|
// -> `.webp` is part of the URL so that the extension matches what is served, but the ID is the
|
||||||
|
// only part that identifies anything
|
||||||
|
const assetId = req.params.fileName.replace(/\.webp$/i, '')
|
||||||
|
if (!uuidValidate(assetId)) {
|
||||||
|
return reply.notFound('Thumbnail not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await WIKI.models.assets.getThumbnail(assetId)
|
||||||
|
if (!preview) {
|
||||||
|
return reply.notFound('Thumbnail not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `"${crypto.createHash('sha1').update(preview).digest('hex')}"`
|
||||||
|
reply.header('ETag', etag)
|
||||||
|
reply.header('Cache-Control', THUMB_CACHE)
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
if (req.headers['if-none-match'] === etag) {
|
||||||
|
return reply.code(304).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.type('image/webp').send(preview)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,352 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import mime from 'mime'
|
||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||||
|
import { assets as assetsTable, tree as treeTable } from '../db/schema.ts'
|
||||||
|
import { CustomError, decodeTreePath } from '../helpers/common.ts'
|
||||||
|
import { makeImageThumbnail } from '../helpers/images.ts'
|
||||||
|
|
||||||
|
/** How large the file manager renders a preview. Generated once, at upload time. */
|
||||||
|
const THUMBNAIL_SIZE = { width: 320, height: 200 }
|
||||||
|
|
||||||
|
/** What an asset is, for the sake of grouping and filtering. Mirrors the `assetKind` schema enum. */
|
||||||
|
export type AssetKind = 'document' | 'image' | 'other'
|
||||||
|
|
||||||
|
/** Extensions that count as a document rather than "other". */
|
||||||
|
const DOCUMENT_EXTS = new Set([
|
||||||
|
'csv',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'epub',
|
||||||
|
'md',
|
||||||
|
'odp',
|
||||||
|
'ods',
|
||||||
|
'odt',
|
||||||
|
'pdf',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'rtf',
|
||||||
|
'txt',
|
||||||
|
'xls',
|
||||||
|
'xlsx'
|
||||||
|
])
|
||||||
|
|
||||||
|
/** An asset's metadata, as exposed by the API. */
|
||||||
|
export interface Asset {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
fileExt: string
|
||||||
|
kind: AssetKind
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
/** Slash-separated, without a leading or trailing slash. Empty at the site root. */
|
||||||
|
folderPath: string
|
||||||
|
title: string
|
||||||
|
hasPreview: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce whatever a client called the file to something safe to store, address and serve.
|
||||||
|
*
|
||||||
|
* Any directory part is dropped — the folder comes from the request, never from the name — and what
|
||||||
|
* is left is lowercased down to the characters that survive a URL untouched, which is the same bar
|
||||||
|
* folder path names are held to.
|
||||||
|
*/
|
||||||
|
export function sanitizeFileName(input: string): string {
|
||||||
|
const base = path.basename(input.trim().replaceAll('\\', '/'))
|
||||||
|
const cleaned = base
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/\s+/g, '-')
|
||||||
|
.replaceAll(/[^a-z0-9._-]/g, '')
|
||||||
|
// -> A leading dot would make it a hidden file, and a run of them can walk out of the folder
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replaceAll(/\.{2,}/g, '.')
|
||||||
|
return cleaned.slice(0, 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extension, lowercase and without its dot. Empty when the name has none.
|
||||||
|
*/
|
||||||
|
function extensionOf(fileName: string): string {
|
||||||
|
return path.extname(fileName).replace(/^\./, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindOf(mimeType: string, fileExt: string): AssetKind {
|
||||||
|
if (mimeType.startsWith('image/')) {
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mimeType === 'application/pdf' ||
|
||||||
|
mimeType.startsWith('text/') ||
|
||||||
|
DOCUMENT_EXTS.has(fileExt)
|
||||||
|
) {
|
||||||
|
return 'document'
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets model
|
||||||
|
*
|
||||||
|
* An asset is a file a user uploaded: its bytes live in the `assets` table, while its name and place
|
||||||
|
* in the site live in the matching `tree` row, which shares its ID. Both are written together — an
|
||||||
|
* asset with no tree row would be unreachable, and a tree row with no asset would be a broken link.
|
||||||
|
*
|
||||||
|
* Storage targets are not implemented yet, so the database is the only copy.
|
||||||
|
*/
|
||||||
|
class Assets {
|
||||||
|
/**
|
||||||
|
* Store an uploaded file.
|
||||||
|
*
|
||||||
|
* @param folderId UUID of the folder to upload into. The site root when absent.
|
||||||
|
* @param fileName What to call it. Sanitized, so what comes back may differ from what went in.
|
||||||
|
* @param data The file itself.
|
||||||
|
*/
|
||||||
|
async upload({
|
||||||
|
siteId,
|
||||||
|
locale,
|
||||||
|
folderId,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
data,
|
||||||
|
authorId
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
locale: string
|
||||||
|
folderId?: string | null
|
||||||
|
fileName: string
|
||||||
|
mimeType?: string | null
|
||||||
|
data: Buffer
|
||||||
|
authorId: string
|
||||||
|
}): Promise<Asset> {
|
||||||
|
const safeName = sanitizeFileName(fileName)
|
||||||
|
if (!safeName) {
|
||||||
|
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
|
||||||
|
}
|
||||||
|
const fileExt = extensionOf(safeName)
|
||||||
|
// -> The extension decides the type, not the request: the declared one is whatever the client felt
|
||||||
|
// like sending, and this value is what gets served back to a browser later
|
||||||
|
const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream'
|
||||||
|
const kind = kindOf(resolvedMime, fileExt)
|
||||||
|
|
||||||
|
const preview =
|
||||||
|
kind === 'image'
|
||||||
|
? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// -> The tree row goes in first: it owns the name, and it is what settles a collision with
|
||||||
|
// something already in the folder before any bytes are written. What comes back is the name
|
||||||
|
// that was actually free, which is not always the one asked for.
|
||||||
|
const entry = await WIKI.models.tree.addAsset({
|
||||||
|
parentId: folderId,
|
||||||
|
fileName: safeName,
|
||||||
|
title: safeName,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
meta: {
|
||||||
|
fileSize: data.length,
|
||||||
|
fileExt,
|
||||||
|
mimeType: resolvedMime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const storedName = entry.fileName
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WIKI.db.insert(assetsTable).values({
|
||||||
|
id: entry.id,
|
||||||
|
fileName: storedName,
|
||||||
|
fileExt,
|
||||||
|
kind,
|
||||||
|
mimeType: resolvedMime,
|
||||||
|
fileSize: data.length,
|
||||||
|
data,
|
||||||
|
preview,
|
||||||
|
authorId,
|
||||||
|
siteId
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// -> Nothing points at the tree row now, and leaving it would show a file the site cannot serve
|
||||||
|
await WIKI.db.delete(treeTable).where(eq(treeTable.id, entry.id))
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
WIKI.models.hooks.emit('asset:upload', {
|
||||||
|
id: entry.id,
|
||||||
|
fileName: storedName,
|
||||||
|
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '',
|
||||||
|
siteId,
|
||||||
|
authorId,
|
||||||
|
metadata: { fileSize: data.length, mimeType: resolvedMime, kind }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
fileName: storedName,
|
||||||
|
fileExt,
|
||||||
|
kind,
|
||||||
|
mimeType: resolvedMime,
|
||||||
|
fileSize: data.length,
|
||||||
|
folderPath: decodeTreePath(entry.folderPath ?? '') ?? '',
|
||||||
|
title: entry.title,
|
||||||
|
hasPreview: Boolean(preview),
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An asset's metadata, without its bytes. Null if there is no such asset on this site.
|
||||||
|
*/
|
||||||
|
async getAsset(siteId: string, id: string): Promise<Asset | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select({
|
||||||
|
id: assetsTable.id,
|
||||||
|
fileName: assetsTable.fileName,
|
||||||
|
fileExt: assetsTable.fileExt,
|
||||||
|
kind: assetsTable.kind,
|
||||||
|
mimeType: assetsTable.mimeType,
|
||||||
|
fileSize: assetsTable.fileSize,
|
||||||
|
createdAt: assetsTable.createdAt,
|
||||||
|
updatedAt: assetsTable.updatedAt,
|
||||||
|
folderPath: treeTable.folderPath,
|
||||||
|
title: treeTable.title,
|
||||||
|
// -> Only whether there is one: the preview itself can be megabytes, and no caller of this
|
||||||
|
// wants it inlined
|
||||||
|
hasPreview: sql<boolean>`${assetsTable.preview} IS NOT NULL`
|
||||||
|
})
|
||||||
|
.from(assetsTable)
|
||||||
|
.innerJoin(treeTable, eq(treeTable.id, assetsTable.id))
|
||||||
|
.where(and(eq(assetsTable.id, id), eq(assetsTable.siteId, siteId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = results[0]
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
fileSize: row.fileSize ?? 0,
|
||||||
|
folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
|
||||||
|
hasPreview: Boolean(row.hasPreview)
|
||||||
|
} as Asset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An asset's bytes, along with what to serve them as. Null if there is no such asset.
|
||||||
|
*
|
||||||
|
* Not scoped to a site, unlike the rest: the ID is a UUID nobody can guess, and the routes that use
|
||||||
|
* this are the public ones, which have no site of their own to check against.
|
||||||
|
*/
|
||||||
|
async getContent(
|
||||||
|
id: string
|
||||||
|
): Promise<{ data: Buffer; mimeType: string; fileName: string } | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select({
|
||||||
|
data: assetsTable.data,
|
||||||
|
mimeType: assetsTable.mimeType,
|
||||||
|
fileName: assetsTable.fileName
|
||||||
|
})
|
||||||
|
.from(assetsTable)
|
||||||
|
.where(eq(assetsTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
const row = results[0]
|
||||||
|
return row?.data ? { data: row.data, mimeType: row.mimeType, fileName: row.fileName } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An asset's thumbnail, or null when it has none — which is the normal state for anything that is
|
||||||
|
* not an image, and for images uploaded while Sharp was unavailable.
|
||||||
|
*/
|
||||||
|
async getThumbnail(id: string): Promise<Buffer | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select({ preview: assetsTable.preview })
|
||||||
|
.from(assetsTable)
|
||||||
|
.where(eq(assetsTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
return results[0]?.preview ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename an asset, in both of the rows that describe it.
|
||||||
|
*
|
||||||
|
* @returns The updated metadata, or null if there is no such asset on this site
|
||||||
|
*/
|
||||||
|
async renameAsset(siteId: string, id: string, fileName: string): Promise<Asset | null> {
|
||||||
|
const asset = await this.getAsset(siteId, id)
|
||||||
|
if (!asset) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const safeName = sanitizeFileName(fileName)
|
||||||
|
if (!safeName) {
|
||||||
|
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
|
||||||
|
}
|
||||||
|
const fileExt = extensionOf(safeName)
|
||||||
|
if (!fileExt) {
|
||||||
|
throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.')
|
||||||
|
}
|
||||||
|
const resolvedMime = mime.getType(safeName) ?? asset.mimeType
|
||||||
|
|
||||||
|
await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName })
|
||||||
|
await WIKI.db
|
||||||
|
.update(assetsTable)
|
||||||
|
.set({
|
||||||
|
fileName: safeName,
|
||||||
|
fileExt,
|
||||||
|
mimeType: resolvedMime,
|
||||||
|
kind: kindOf(resolvedMime, fileExt),
|
||||||
|
updatedAt: sql`now()`
|
||||||
|
})
|
||||||
|
.where(eq(assetsTable.id, id))
|
||||||
|
// -> The tree carries its own copy of these, and it is what a folder listing reads
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ meta: { fileSize: asset.fileSize, fileExt, mimeType: resolvedMime } })
|
||||||
|
.where(eq(treeTable.id, id))
|
||||||
|
|
||||||
|
WIKI.models.hooks.emit('asset:rename', {
|
||||||
|
id,
|
||||||
|
fileName: safeName,
|
||||||
|
previousFileName: asset.fileName,
|
||||||
|
folderPath: asset.folderPath,
|
||||||
|
siteId
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.getAsset(siteId, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an asset and the tree entry that points at it.
|
||||||
|
*
|
||||||
|
* @returns Whether an asset was deleted
|
||||||
|
*/
|
||||||
|
async deleteAsset(siteId: string, id: string): Promise<boolean> {
|
||||||
|
const asset = await this.getAsset(siteId, id)
|
||||||
|
if (!asset) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await WIKI.db.delete(assetsTable).where(eq(assetsTable.id, id))
|
||||||
|
await WIKI.models.tree.deleteEntry(id)
|
||||||
|
|
||||||
|
WIKI.models.hooks.emit('asset:delete', {
|
||||||
|
id,
|
||||||
|
fileName: asset.fileName,
|
||||||
|
folderPath: asset.folderPath,
|
||||||
|
siteId
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the assets left behind by a folder deletion, which removed their tree entries already.
|
||||||
|
*/
|
||||||
|
async deleteOrphaned(ids: string[]): Promise<void> {
|
||||||
|
if (ids.length < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await WIKI.db.delete(assetsTable).where(inArray(assetsTable.id, ids))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assets = new Assets()
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||||
|
import { navigation as navigationTable, tree as treeTable } from '../db/schema.ts'
|
||||||
|
import { CustomError } from '../helpers/common.ts'
|
||||||
|
|
||||||
|
export const NAVIGATION_MODES = [
|
||||||
|
'inherit',
|
||||||
|
'override',
|
||||||
|
'overrideExact',
|
||||||
|
'hide',
|
||||||
|
'hideExact'
|
||||||
|
] as const
|
||||||
|
export type NavigationMode = (typeof NAVIGATION_MODES)[number]
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
id: string
|
||||||
|
type: 'link' | 'header' | 'separator'
|
||||||
|
label?: string
|
||||||
|
icon?: string
|
||||||
|
target?: string
|
||||||
|
openInNewWindow?: boolean
|
||||||
|
visibilityGroups?: string[]
|
||||||
|
children?: NavigationItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNavigationResult {
|
||||||
|
navigationMode: NavigationMode
|
||||||
|
navigationId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An item is visible when it names no group, or names one the viewer belongs to. */
|
||||||
|
function isVisibleTo(item: NavigationItem, userGroups: string[]): boolean {
|
||||||
|
const groups = item.visibilityGroups ?? []
|
||||||
|
return groups.length < 1 || groups.some((g) => userGroups.includes(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation model
|
||||||
|
*
|
||||||
|
* A navigation menu is a row of `items` keyed by the id of whatever it belongs to: a tree entry that
|
||||||
|
* overrides the menu below it, or — for the site-wide menu every page falls back to — the site's own
|
||||||
|
* id. That double use of the key is why the id alone is enough to fetch a menu, and why the home page
|
||||||
|
* edits the site menu rather than one of its own.
|
||||||
|
*
|
||||||
|
* Which menu a page gets is decided when the mode is saved rather than when the page is rendered:
|
||||||
|
* every tree entry carries the resolved `navigationId`, so drawing a sidebar is one lookup.
|
||||||
|
*/
|
||||||
|
class Navigation {
|
||||||
|
/**
|
||||||
|
* The items of one menu.
|
||||||
|
*
|
||||||
|
* @param id Menu id — a tree entry id, or a site id for the site-wide menu
|
||||||
|
* @param userGroups Groups the viewer belongs to. Items limited to other groups are dropped, at both
|
||||||
|
* levels, unless `unfiltered` is set.
|
||||||
|
* @param unfiltered Return every item regardless of visibility, which is what editing one needs —
|
||||||
|
* an editor that could not see an item would drop it on the next save.
|
||||||
|
*/
|
||||||
|
async getNav(
|
||||||
|
id: string,
|
||||||
|
{ userGroups = [], unfiltered = false }: { userGroups?: string[]; unfiltered?: boolean } = {}
|
||||||
|
): Promise<NavigationItem[]> {
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({ items: navigationTable.items })
|
||||||
|
.from(navigationTable)
|
||||||
|
.where(eq(navigationTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const items = (rows[0]?.items ?? []) as NavigationItem[]
|
||||||
|
if (unfiltered) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.filter((item) => isVisibleTo(item, userGroups))
|
||||||
|
.map((item) =>
|
||||||
|
item.children?.length
|
||||||
|
? { ...item, children: item.children.filter((c) => isVisibleTo(c, userGroups)) }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu the site as a whole uses, which is the one every page inherits by default.
|
||||||
|
*
|
||||||
|
* Created empty on demand: a site made before this row existed, or one whose menu was never edited,
|
||||||
|
* has nothing stored, and an absent menu is an empty one rather than an error.
|
||||||
|
*/
|
||||||
|
async ensureSiteNav(siteId: string): Promise<void> {
|
||||||
|
await WIKI.db
|
||||||
|
.insert(navigationTable)
|
||||||
|
.values({ id: siteId, siteId, items: [] })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the menus belonging to tree entries that no longer exist.
|
||||||
|
*
|
||||||
|
* A menu is keyed by the id of the entry that owns it, so deleting a page or a folder would
|
||||||
|
* otherwise leave its menu behind with nothing able to reach it. The site's own menu is keyed by the
|
||||||
|
* site id and is never a tree entry, so it is not at risk here.
|
||||||
|
*
|
||||||
|
* @param ids Tree entry ids being removed
|
||||||
|
*/
|
||||||
|
async deleteNavForEntries(ids: string[]): Promise<void> {
|
||||||
|
if (ids.length < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await WIKI.db.delete(navigationTable).where(inArray(navigationTable.id, ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu a tree entry falls back to: the nearest ancestor that overrides or hides, or the
|
||||||
|
* site-wide menu when nothing above it does either.
|
||||||
|
*
|
||||||
|
* @param siteId Site the entry belongs to, since paths are only unique within one
|
||||||
|
* @param folderPath Encoded ltree path of the folder holding the entry, empty at the site root
|
||||||
|
*/
|
||||||
|
private async ancestorNavId(siteId: string, folderPath: string): Promise<string | null> {
|
||||||
|
if (!folderPath) {
|
||||||
|
return siteId
|
||||||
|
}
|
||||||
|
const result = await WIKI.db.execute(sql`
|
||||||
|
SELECT "navigationId"
|
||||||
|
FROM tree
|
||||||
|
WHERE "siteId" = ${siteId}
|
||||||
|
AND ("folderPath" || "fileName") @> ${folderPath}::ltree
|
||||||
|
AND "navigationMode" IN ('override', 'hide')
|
||||||
|
ORDER BY nlevel("folderPath" || "fileName") DESC
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
const rows = (result.rows ?? result) as any[]
|
||||||
|
return rows.length > 0 ? (rows[0].navigationId ?? null) : siteId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set how a page decides its sidebar, and optionally the menu itself.
|
||||||
|
*
|
||||||
|
* Two things move here. The entry records its own mode and the menu it resolves to, and — when the
|
||||||
|
* change alters what descendants inherit — every entry below it that is still on `inherit` is
|
||||||
|
* repointed, stopping at any that overrides or hides in between.
|
||||||
|
*
|
||||||
|
* @param items When given, the menu stored against this entry, replacing whatever was there
|
||||||
|
*/
|
||||||
|
async updateNavigation({
|
||||||
|
siteId,
|
||||||
|
pageId,
|
||||||
|
mode,
|
||||||
|
items
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
pageId: string
|
||||||
|
mode: NavigationMode
|
||||||
|
items?: NavigationItem[]
|
||||||
|
}): Promise<UpdateNavigationResult> {
|
||||||
|
const entries = await WIKI.db
|
||||||
|
.select()
|
||||||
|
.from(treeTable)
|
||||||
|
.where(and(eq(treeTable.id, pageId), eq(treeTable.siteId, siteId)))
|
||||||
|
.limit(1)
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry) {
|
||||||
|
throw new CustomError('navInvalidPage', 'This page does not exist.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Whatever this change resolves to, `inherit` ultimately falls back to the site menu, and a
|
||||||
|
// site created before that row existed does not have one yet
|
||||||
|
await this.ensureSiteNav(siteId)
|
||||||
|
|
||||||
|
const folderPath = entry.folderPath ?? ''
|
||||||
|
// -> The home page at the root edits the site-wide menu rather than one of its own, which is what
|
||||||
|
// makes it the menu every other page inherits
|
||||||
|
const isSiteRoot = folderPath === '' && entry.fileName === 'home'
|
||||||
|
const ownNavId = isSiteRoot ? siteId : entry.id
|
||||||
|
const fullPath = folderPath ? `${folderPath}.${entry.fileName}` : entry.fileName
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
await WIKI.db
|
||||||
|
.insert(navigationTable)
|
||||||
|
.values({ id: ownNavId, siteId, items })
|
||||||
|
.onConflictDoUpdate({ target: navigationTable.id, set: { items } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ancestorId = await this.ancestorNavId(siteId, folderPath)
|
||||||
|
// -> A mode that stops applying below this entry hands its descendants back to the ancestor
|
||||||
|
const wasCascading = ['override', 'hide'].includes(entry.navigationMode)
|
||||||
|
|
||||||
|
let navId: string | null = null
|
||||||
|
let cascadeTo: string | null | undefined
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'inherit': {
|
||||||
|
navId = ancestorId
|
||||||
|
if (wasCascading) {
|
||||||
|
cascadeTo = ancestorId
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'override': {
|
||||||
|
navId = ownNavId
|
||||||
|
cascadeTo = ownNavId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'overrideExact': {
|
||||||
|
navId = ownNavId
|
||||||
|
if (wasCascading) {
|
||||||
|
cascadeTo = ancestorId
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'hide': {
|
||||||
|
navId = null
|
||||||
|
cascadeTo = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'hideExact': {
|
||||||
|
navId = null
|
||||||
|
if (wasCascading) {
|
||||||
|
cascadeTo = ancestorId
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ navigationMode: mode, navigationId: navId })
|
||||||
|
.where(eq(treeTable.id, entry.id))
|
||||||
|
|
||||||
|
if (cascadeTo !== undefined) {
|
||||||
|
// -> Everything below that still inherits, except what sits under a nearer override or hide,
|
||||||
|
// which owns its own subtree
|
||||||
|
await WIKI.db.execute(sql`
|
||||||
|
UPDATE tree tt
|
||||||
|
SET "navigationId" = ${cascadeTo}
|
||||||
|
WHERE tt."siteId" = ${siteId}
|
||||||
|
AND tt.tree IN ('page', 'folder')
|
||||||
|
AND tt."folderPath" <@ ${fullPath}::ltree
|
||||||
|
AND tt."navigationMode" = 'inherit'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tree tc
|
||||||
|
WHERE tc."siteId" = ${siteId}
|
||||||
|
AND tc.tree IN ('page', 'folder')
|
||||||
|
AND tc."folderPath" <@ ${fullPath}::ltree
|
||||||
|
AND (tc."folderPath" || tc."fileName") @> tt."folderPath"
|
||||||
|
AND tc."navigationMode" IN ('override', 'hide')
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { navigationMode: mode, navigationId: navId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigation = new Navigation()
|
||||||
@ -0,0 +1,741 @@
|
|||||||
|
import { and, eq, isNull, ne, sql } from 'drizzle-orm'
|
||||||
|
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
|
||||||
|
import { CustomError, generatePathHash } from '../helpers/common.ts'
|
||||||
|
import type { TocNode } from './rendering.ts'
|
||||||
|
|
||||||
|
/** What each editor produces, which is what the content column holds. */
|
||||||
|
const EDITOR_CONTENT_TYPES: Record<string, string> = {
|
||||||
|
markdown: 'markdown',
|
||||||
|
asciidoc: 'asciidoc',
|
||||||
|
wysiwyg: 'html'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page path is what ends up in a URL, so it is held to what reads and routes cleanly. */
|
||||||
|
const rePagePath = /^[a-zA-Z0-9-_/]*$/
|
||||||
|
const reAlias = /^[a-zA-Z0-9-_]*$/
|
||||||
|
|
||||||
|
/** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */
|
||||||
|
const CONFIG_FIELDS = [
|
||||||
|
'allowComments',
|
||||||
|
'allowContributions',
|
||||||
|
'allowRatings',
|
||||||
|
'showSidebar',
|
||||||
|
'showTags',
|
||||||
|
'showToc',
|
||||||
|
'tocDepth'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** A page as the API exposes it: the columns and both blobs, flattened into one object. */
|
||||||
|
export interface Page {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
hash: string
|
||||||
|
alias: string | null
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
locale: string
|
||||||
|
editor: string
|
||||||
|
contentType: string
|
||||||
|
publishState: 'draft' | 'published' | 'scheduled'
|
||||||
|
publishStartDate: Date | null
|
||||||
|
publishEndDate: Date | null
|
||||||
|
isBrowsable: boolean
|
||||||
|
isSearchable: boolean
|
||||||
|
password: string | null
|
||||||
|
relations: any[]
|
||||||
|
tags: string[]
|
||||||
|
toc: TocNode[]
|
||||||
|
render: string
|
||||||
|
content?: string
|
||||||
|
allowComments: boolean
|
||||||
|
allowContributions: boolean
|
||||||
|
allowRatings: boolean
|
||||||
|
showSidebar: boolean
|
||||||
|
showTags: boolean
|
||||||
|
showToc: boolean
|
||||||
|
tocDepth: { min: number; max: number }
|
||||||
|
scriptJsLoad: string
|
||||||
|
scriptJsUnload: string
|
||||||
|
scriptCss: string
|
||||||
|
navigationId: string | null
|
||||||
|
navigationMode: string
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a page can be created with. */
|
||||||
|
export interface PageInput {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
editor: string
|
||||||
|
content: string
|
||||||
|
/** The HTML the editor produced. Post-processed before it is stored — see `models/rendering.ts`. */
|
||||||
|
render?: string
|
||||||
|
locale?: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
alias?: string
|
||||||
|
publishState?: 'draft' | 'published' | 'scheduled'
|
||||||
|
publishStartDate?: string | null
|
||||||
|
publishEndDate?: string | null
|
||||||
|
isBrowsable?: boolean
|
||||||
|
isSearchable?: boolean
|
||||||
|
password?: string
|
||||||
|
relations?: any[]
|
||||||
|
tags?: string[]
|
||||||
|
allowComments?: boolean
|
||||||
|
allowContributions?: boolean
|
||||||
|
allowRatings?: boolean
|
||||||
|
showSidebar?: boolean
|
||||||
|
showTags?: boolean
|
||||||
|
showToc?: boolean
|
||||||
|
tocDepth?: { min: number; max: number }
|
||||||
|
scriptJsLoad?: string
|
||||||
|
scriptJsUnload?: string
|
||||||
|
scriptCss?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who is saving, and what they are allowed to put in a page. */
|
||||||
|
export interface PageActor {
|
||||||
|
id: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(actor: PageActor, permission: string): boolean {
|
||||||
|
return actor.permissions.includes('manage:system') || actor.permissions.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a path down to the form that gets stored: no wrapping slashes, lowercase.
|
||||||
|
*/
|
||||||
|
function normalizePath(input: string): string {
|
||||||
|
const path = (input ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase()
|
||||||
|
if (!rePagePath.test(path)) {
|
||||||
|
throw new CustomError(
|
||||||
|
'pageInvalidPath',
|
||||||
|
'A page path may only contain alphanumeric, hyphen, underscore and slash characters.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages model
|
||||||
|
*
|
||||||
|
* A page is a row here plus a row in the tree that gives it its place in the site. The markdown is
|
||||||
|
* authored and rendered in the browser; what arrives is both the source and the HTML, and the HTML is
|
||||||
|
* run through `models/rendering.ts` before being stored — that is where it gets sanitized against what
|
||||||
|
* the author is actually allowed to embed, and where the table of contents and the search text come
|
||||||
|
* from.
|
||||||
|
*
|
||||||
|
* Not implemented yet, and deliberately not faked here: version history (there is no table for it),
|
||||||
|
* page links, comments, and storage targets.
|
||||||
|
*/
|
||||||
|
class Pages {
|
||||||
|
/**
|
||||||
|
* Flatten a row and its blobs into the shape the API returns.
|
||||||
|
*/
|
||||||
|
private toPage(row: any, { withContent = false }: { withContent?: boolean } = {}): Page {
|
||||||
|
const config = row.config ?? {}
|
||||||
|
const scripts = row.scripts ?? {}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
path: row.path,
|
||||||
|
hash: row.hash,
|
||||||
|
alias: row.alias,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
icon: row.icon,
|
||||||
|
locale: row.locale,
|
||||||
|
editor: row.editor,
|
||||||
|
contentType: row.contentType,
|
||||||
|
publishState: row.publishState,
|
||||||
|
publishStartDate: row.publishStartDate,
|
||||||
|
publishEndDate: row.publishEndDate,
|
||||||
|
isBrowsable: row.isBrowsable,
|
||||||
|
isSearchable: row.isSearchable,
|
||||||
|
password: row.password,
|
||||||
|
relations: row.relations ?? [],
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
toc: row.toc ?? [],
|
||||||
|
render: row.render ?? '',
|
||||||
|
...(withContent ? { content: row.content ?? '' } : {}),
|
||||||
|
allowComments: config.allowComments ?? true,
|
||||||
|
allowContributions: config.allowContributions ?? true,
|
||||||
|
allowRatings: config.allowRatings ?? true,
|
||||||
|
showSidebar: config.showSidebar ?? true,
|
||||||
|
showTags: config.showTags ?? true,
|
||||||
|
showToc: config.showToc ?? true,
|
||||||
|
tocDepth: config.tocDepth ?? { min: 1, max: 2 },
|
||||||
|
scriptJsLoad: scripts.jsLoad ?? '',
|
||||||
|
scriptJsUnload: scripts.jsUnload ?? '',
|
||||||
|
scriptCss: scripts.css ?? '',
|
||||||
|
navigationId: row.navigationId ?? null,
|
||||||
|
navigationMode: row.navigationMode ?? 'inherit',
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: row.authorName ?? '',
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single page, by ID or by the hash of its path.
|
||||||
|
*
|
||||||
|
* The hash is what the frontend addresses a page with — see `generatePathHash` — so this is the
|
||||||
|
* lookup an ordinary page view goes through.
|
||||||
|
*/
|
||||||
|
async getPage({
|
||||||
|
siteId,
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
locale,
|
||||||
|
withContent = false,
|
||||||
|
publicOnly = false
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
id?: string
|
||||||
|
hash?: string
|
||||||
|
locale?: string
|
||||||
|
withContent?: boolean
|
||||||
|
/** Restrict to what a reader with no session may see: published, and not password protected. */
|
||||||
|
publicOnly?: boolean
|
||||||
|
}): Promise<Page | null> {
|
||||||
|
const conditions = [eq(pagesTable.siteId, siteId)]
|
||||||
|
if (publicOnly) {
|
||||||
|
// -> Page-level access rules are not implemented, so this is the whole of it: an anonymous
|
||||||
|
// reader sees published pages that are not behind a password, and nothing else
|
||||||
|
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||||
|
conditions.push(isNull(pagesTable.password))
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
conditions.push(eq(pagesTable.id, id))
|
||||||
|
} else if (hash) {
|
||||||
|
conditions.push(eq(pagesTable.hash, hash))
|
||||||
|
// -> A path is only unique within a locale, so without one this could match more than one page
|
||||||
|
conditions.push(eq(pagesTable.locale, locale ?? this.defaultLocale(siteId)))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select({
|
||||||
|
page: pagesTable,
|
||||||
|
authorName: usersTable.name,
|
||||||
|
navigationId: treeTable.navigationId,
|
||||||
|
navigationMode: treeTable.navigationMode
|
||||||
|
})
|
||||||
|
.from(pagesTable)
|
||||||
|
.leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId))
|
||||||
|
.leftJoin(treeTable, eq(treeTable.id, pagesTable.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = results[0]
|
||||||
|
if (!row) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.toPage(
|
||||||
|
{
|
||||||
|
...row.page,
|
||||||
|
authorName: row.authorName,
|
||||||
|
navigationId: row.navigationId,
|
||||||
|
navigationMode: row.navigationMode
|
||||||
|
},
|
||||||
|
{ withContent }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a page.
|
||||||
|
*
|
||||||
|
* @param actor Who is saving it. Their permissions decide what survives sanitizing.
|
||||||
|
*/
|
||||||
|
async createPage(siteId: string, input: PageInput, actor: PageActor): Promise<Page> {
|
||||||
|
if (!WIKI.sites[siteId]) {
|
||||||
|
throw new CustomError('pageInvalidSite', 'This site does not exist.', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = normalizePath(input.path)
|
||||||
|
const locale = input.locale || this.defaultLocale(siteId)
|
||||||
|
const title = (input.title ?? '').trim()
|
||||||
|
if (title.length < 1) {
|
||||||
|
throw new CustomError('pageTitleMissing', 'A page needs a title.')
|
||||||
|
}
|
||||||
|
if (!input.content || input.content.trim().length < 1) {
|
||||||
|
throw new CustomError('pageEmptyContent', 'A page cannot be empty.')
|
||||||
|
}
|
||||||
|
const editor = input.editor || 'markdown'
|
||||||
|
|
||||||
|
const hash = generatePathHash(path)
|
||||||
|
const duplicate = await WIKI.db
|
||||||
|
.select({ id: pagesTable.id })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(
|
||||||
|
and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale), eq(pagesTable.path, path))
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (duplicate.length > 0) {
|
||||||
|
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = await this.validateAlias(siteId, input.alias)
|
||||||
|
const { render, toc, text } = WIKI.models.rendering.postProcess(input.render ?? '', {
|
||||||
|
scripts: hasPermission(actor, 'write:scripts'),
|
||||||
|
styles: hasPermission(actor, 'write:styles')
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathParts = path.split('/')
|
||||||
|
const inserted = await WIKI.db
|
||||||
|
.insert(pagesTable)
|
||||||
|
.values({
|
||||||
|
alias,
|
||||||
|
authorId: actor.id,
|
||||||
|
creatorId: actor.id,
|
||||||
|
ownerId: actor.id,
|
||||||
|
config: this.buildConfig(input, siteId),
|
||||||
|
content: input.content,
|
||||||
|
contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text',
|
||||||
|
description: input.description ?? '',
|
||||||
|
editor,
|
||||||
|
hash,
|
||||||
|
icon: input.icon ?? '',
|
||||||
|
isBrowsable: input.isBrowsable ?? true,
|
||||||
|
isSearchable: input.isSearchable ?? true,
|
||||||
|
locale,
|
||||||
|
password: input.password ?? null,
|
||||||
|
path,
|
||||||
|
publishState: input.publishState ?? 'published',
|
||||||
|
publishStartDate: input.publishStartDate ? new Date(input.publishStartDate) : null,
|
||||||
|
publishEndDate: input.publishEndDate ? new Date(input.publishEndDate) : null,
|
||||||
|
relations: input.relations ?? [],
|
||||||
|
render,
|
||||||
|
searchContent: text,
|
||||||
|
scripts: this.buildScripts(input, actor),
|
||||||
|
siteId,
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
title,
|
||||||
|
toc
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
const page = inserted[0]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WIKI.models.tree.addPage({
|
||||||
|
id: page.id,
|
||||||
|
parentPath: pathParts.slice(0, -1).join('/'),
|
||||||
|
fileName: pathParts.at(-1)!,
|
||||||
|
title: page.title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
meta: this.treeMeta(page)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// -> A page with no tree entry is invisible to navigation and to the file manager, which is
|
||||||
|
// worse than not having saved it at all
|
||||||
|
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, page.id))
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.models.search.indexPage(page.id, locale)
|
||||||
|
await WIKI.models.hooks.emit('page:create', {
|
||||||
|
id: page.id,
|
||||||
|
path: page.path,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
authorId: actor.id,
|
||||||
|
metadata: { title: page.title, description: page.description, editor }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (await this.getPage({ siteId, id: page.id })) as Page
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a page. Only the fields present in the patch are touched.
|
||||||
|
*/
|
||||||
|
async updatePage(
|
||||||
|
siteId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<PageInput>,
|
||||||
|
actor: PageActor
|
||||||
|
): Promise<Page | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select()
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId)))
|
||||||
|
.limit(1)
|
||||||
|
const existing = results[0]
|
||||||
|
if (!existing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: Record<string, any> = { updatedAt: sql`now()` }
|
||||||
|
let treeTitle: string | null = null
|
||||||
|
|
||||||
|
if (patch.title !== undefined) {
|
||||||
|
const title = patch.title.trim()
|
||||||
|
if (title.length < 1) {
|
||||||
|
throw new CustomError('pageTitleMissing', 'A page needs a title.')
|
||||||
|
}
|
||||||
|
values.title = title
|
||||||
|
treeTitle = title
|
||||||
|
}
|
||||||
|
if (patch.description !== undefined) {
|
||||||
|
values.description = patch.description.trim()
|
||||||
|
}
|
||||||
|
if (patch.icon !== undefined) {
|
||||||
|
values.icon = patch.icon.trim()
|
||||||
|
}
|
||||||
|
if (patch.alias !== undefined) {
|
||||||
|
values.alias = await this.validateAlias(siteId, patch.alias, id)
|
||||||
|
}
|
||||||
|
if (patch.content !== undefined) {
|
||||||
|
values.content = patch.content
|
||||||
|
}
|
||||||
|
if (patch.publishState !== undefined) {
|
||||||
|
if (
|
||||||
|
patch.publishState === 'scheduled' &&
|
||||||
|
!(patch.publishStartDate ?? existing.publishStartDate) &&
|
||||||
|
!(patch.publishEndDate ?? existing.publishEndDate)
|
||||||
|
) {
|
||||||
|
throw new CustomError(
|
||||||
|
'pageMissingScheduledDates',
|
||||||
|
'A scheduled page needs a start or an end date.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
values.publishState = patch.publishState
|
||||||
|
}
|
||||||
|
if (patch.publishStartDate !== undefined) {
|
||||||
|
values.publishStartDate = patch.publishStartDate ? new Date(patch.publishStartDate) : null
|
||||||
|
}
|
||||||
|
if (patch.publishEndDate !== undefined) {
|
||||||
|
values.publishEndDate = patch.publishEndDate ? new Date(patch.publishEndDate) : null
|
||||||
|
}
|
||||||
|
if (patch.isBrowsable !== undefined) {
|
||||||
|
values.isBrowsable = patch.isBrowsable
|
||||||
|
}
|
||||||
|
if (patch.isSearchable !== undefined) {
|
||||||
|
values.isSearchable = patch.isSearchable
|
||||||
|
}
|
||||||
|
if (patch.password !== undefined) {
|
||||||
|
values.password = patch.password || null
|
||||||
|
}
|
||||||
|
if (patch.relations !== undefined) {
|
||||||
|
values.relations = patch.relations
|
||||||
|
}
|
||||||
|
if (patch.tags !== undefined) {
|
||||||
|
values.tags = patch.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> A render only means anything next to the content it came from, so the two move together
|
||||||
|
if (patch.render !== undefined) {
|
||||||
|
const { render, toc, text } = WIKI.models.rendering.postProcess(patch.render, {
|
||||||
|
scripts: hasPermission(actor, 'write:scripts'),
|
||||||
|
styles: hasPermission(actor, 'write:styles')
|
||||||
|
})
|
||||||
|
values.render = render
|
||||||
|
values.toc = toc
|
||||||
|
values.searchContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) {
|
||||||
|
values.config = this.buildConfig(patch, siteId, existing.config as Record<string, any>)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
patch.scriptJsLoad !== undefined ||
|
||||||
|
patch.scriptJsUnload !== undefined ||
|
||||||
|
patch.scriptCss !== undefined
|
||||||
|
) {
|
||||||
|
values.scripts = this.buildScripts(patch, actor, existing.scripts as Record<string, any>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> The author is whoever last changed it; the creator and owner do not move
|
||||||
|
values.authorId = actor.id
|
||||||
|
|
||||||
|
await WIKI.db.update(pagesTable).set(values).where(eq(pagesTable.id, id))
|
||||||
|
|
||||||
|
const updated = (await this.getPage({ siteId, id })) as Page
|
||||||
|
|
||||||
|
if (treeTitle !== null || patch.tags !== undefined) {
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({
|
||||||
|
...(treeTitle !== null ? { title: treeTitle } : {}),
|
||||||
|
...(patch.tags !== undefined ? { tags: patch.tags } : {}),
|
||||||
|
meta: this.treeMeta(updated),
|
||||||
|
updatedAt: sql`now()`
|
||||||
|
})
|
||||||
|
.where(eq(treeTable.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.models.search.indexPage(id, updated.locale)
|
||||||
|
await WIKI.models.hooks.emit('page:edit', {
|
||||||
|
id,
|
||||||
|
path: updated.path,
|
||||||
|
locale: updated.locale,
|
||||||
|
siteId,
|
||||||
|
authorId: actor.id,
|
||||||
|
metadata: { title: updated.title, description: updated.description }
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a page to another path, taking its tree entry with it.
|
||||||
|
*/
|
||||||
|
async movePage(
|
||||||
|
siteId: string,
|
||||||
|
id: string,
|
||||||
|
{ path, title }: { path: string; title?: string },
|
||||||
|
actor: PageActor
|
||||||
|
): Promise<Page | null> {
|
||||||
|
const page = await this.getPage({ siteId, id })
|
||||||
|
if (!page) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const newPath = normalizePath(path)
|
||||||
|
if (newPath === page.path && (title === undefined || title === page.title)) {
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPath !== page.path) {
|
||||||
|
const duplicate = await WIKI.db
|
||||||
|
.select({ id: pagesTable.id })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
ne(pagesTable.id, id),
|
||||||
|
eq(pagesTable.siteId, siteId),
|
||||||
|
eq(pagesTable.locale, page.locale),
|
||||||
|
eq(pagesTable.path, newPath)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (duplicate.length > 0) {
|
||||||
|
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.db
|
||||||
|
.update(pagesTable)
|
||||||
|
.set({
|
||||||
|
path: newPath,
|
||||||
|
hash: generatePathHash(newPath),
|
||||||
|
...(title !== undefined ? { title: title.trim() } : {}),
|
||||||
|
authorId: actor.id,
|
||||||
|
updatedAt: sql`now()`
|
||||||
|
})
|
||||||
|
.where(eq(pagesTable.id, id))
|
||||||
|
|
||||||
|
// -> The tree entry is what places the page in the site, so it is moved rather than rewritten:
|
||||||
|
// dropping and re-adding would create the destination folders but leave the old ones counted
|
||||||
|
const pathParts = newPath.split('/')
|
||||||
|
await WIKI.models.tree.deleteEntry(id)
|
||||||
|
await WIKI.models.tree.addPage({
|
||||||
|
id,
|
||||||
|
parentPath: pathParts.slice(0, -1).join('/'),
|
||||||
|
fileName: pathParts.at(-1)!,
|
||||||
|
title: title !== undefined ? title.trim() : page.title,
|
||||||
|
locale: page.locale,
|
||||||
|
siteId,
|
||||||
|
tags: page.tags,
|
||||||
|
meta: this.treeMeta({ ...page, path: newPath })
|
||||||
|
})
|
||||||
|
|
||||||
|
const moved = (await this.getPage({ siteId, id })) as Page
|
||||||
|
await WIKI.models.hooks.emit('page:rename', {
|
||||||
|
id,
|
||||||
|
path: moved.path,
|
||||||
|
previousPath: page.path,
|
||||||
|
locale: moved.locale,
|
||||||
|
siteId,
|
||||||
|
authorId: actor.id
|
||||||
|
})
|
||||||
|
return moved
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a page and its tree entry.
|
||||||
|
*
|
||||||
|
* @returns Whether a page was deleted
|
||||||
|
*/
|
||||||
|
async deletePage(siteId: string, id: string, actor: PageActor): Promise<boolean> {
|
||||||
|
const page = await this.getPage({ siteId, id })
|
||||||
|
if (!page) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, id))
|
||||||
|
await WIKI.models.tree.deleteEntry(id)
|
||||||
|
// -> A page that overrode the sidebar owns a menu keyed by its own id, which nothing could reach
|
||||||
|
// once the page is gone
|
||||||
|
await WIKI.models.navigation.deleteNavForEntries([id])
|
||||||
|
|
||||||
|
await WIKI.models.hooks.emit('page:delete', {
|
||||||
|
id,
|
||||||
|
path: page.path,
|
||||||
|
locale: page.locale,
|
||||||
|
siteId,
|
||||||
|
authorId: actor.id
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a page again from its source, without going through an editor.
|
||||||
|
*
|
||||||
|
* Needed when a stored render has gone stale — the markdown config changed, or the renderer itself
|
||||||
|
* did — and there is nobody with the page open to re-save it. The rendering goes through the very
|
||||||
|
* same frontend pipeline, driven in a headless browser, so the result is what the editor would have
|
||||||
|
* produced.
|
||||||
|
*/
|
||||||
|
async rerenderPage(siteId: string, id: string, actor: PageActor): Promise<Page | null> {
|
||||||
|
const page = await this.getPage({ siteId, id, withContent: true })
|
||||||
|
if (!page) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {}
|
||||||
|
const html = await WIKI.models.rendering.renderContent(page.content ?? '', {
|
||||||
|
editor: page.editor,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
// -> Post-processed like any other render: it came from a browser either way, and the author's
|
||||||
|
// permissions are still what decides what a page may carry
|
||||||
|
const { render, toc, text } = WIKI.models.rendering.postProcess(html, {
|
||||||
|
scripts: hasPermission(actor, 'write:scripts'),
|
||||||
|
styles: hasPermission(actor, 'write:styles')
|
||||||
|
})
|
||||||
|
|
||||||
|
await WIKI.db
|
||||||
|
.update(pagesTable)
|
||||||
|
.set({ render, toc, searchContent: text, updatedAt: sql`now()` })
|
||||||
|
.where(eq(pagesTable.id, id))
|
||||||
|
|
||||||
|
await WIKI.models.search.indexPage(id, page.locale)
|
||||||
|
|
||||||
|
return this.getPage({ siteId, id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a page alias to its path, or null if nothing claims that alias.
|
||||||
|
*/
|
||||||
|
async getPathFromAlias(
|
||||||
|
siteId: string,
|
||||||
|
alias: string
|
||||||
|
): Promise<{ id: string; path: string } | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select({ id: pagesTable.id, path: pagesTable.path })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias)))
|
||||||
|
.limit(1)
|
||||||
|
return results[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale a page belongs to when the request does not say.
|
||||||
|
*/
|
||||||
|
private defaultLocale(siteId: string): string {
|
||||||
|
return WIKI.sites[siteId]?.config?.locales?.primary ?? 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check an alias is well formed and unclaimed, and normalize an empty one to null.
|
||||||
|
*/
|
||||||
|
private async validateAlias(
|
||||||
|
siteId: string,
|
||||||
|
alias: string | undefined,
|
||||||
|
exceptPageId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const value = (alias ?? '').trim()
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!reAlias.test(value)) {
|
||||||
|
throw new CustomError(
|
||||||
|
'pageInvalidAlias',
|
||||||
|
'An alias may only contain alphanumeric, hyphen and underscore characters.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const conditions = [eq(pagesTable.siteId, siteId), eq(pagesTable.alias, value)]
|
||||||
|
if (exceptPageId) {
|
||||||
|
conditions.push(ne(pagesTable.id, exceptPageId))
|
||||||
|
}
|
||||||
|
const duplicate = await WIKI.db
|
||||||
|
.select({ id: pagesTable.id })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(1)
|
||||||
|
if (duplicate.length > 0) {
|
||||||
|
throw new CustomError('pageDuplicateAlias', 'Another page already uses this alias.', 409)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the flat display options back into the `config` blob they are stored in.
|
||||||
|
*/
|
||||||
|
private buildConfig(
|
||||||
|
input: Partial<PageInput>,
|
||||||
|
siteId: string,
|
||||||
|
existing: Record<string, any> = {}
|
||||||
|
): Record<string, any> {
|
||||||
|
const defaults = WIKI.sites[siteId]?.config?.defaults ?? {}
|
||||||
|
return {
|
||||||
|
allowComments: input.allowComments ?? existing.allowComments ?? true,
|
||||||
|
allowContributions: input.allowContributions ?? existing.allowContributions ?? true,
|
||||||
|
allowRatings: input.allowRatings ?? existing.allowRatings ?? true,
|
||||||
|
showSidebar: input.showSidebar ?? existing.showSidebar ?? true,
|
||||||
|
showTags: input.showTags ?? existing.showTags ?? true,
|
||||||
|
showToc: input.showToc ?? existing.showToc ?? true,
|
||||||
|
tocDepth: input.tocDepth ?? existing.tocDepth ?? defaults.tocDepth ?? { min: 1, max: 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same for the per-page scripts — which only an author holding the matching permission may set.
|
||||||
|
*
|
||||||
|
* Silently dropped rather than refused, as with the rest of the sanitizing: an author pasting a
|
||||||
|
* page template that carries scripts should get their page, minus the scripts.
|
||||||
|
*/
|
||||||
|
private buildScripts(
|
||||||
|
input: Partial<PageInput>,
|
||||||
|
actor: PageActor,
|
||||||
|
existing: Record<string, any> = {}
|
||||||
|
): Record<string, any> {
|
||||||
|
const mayScript = hasPermission(actor, 'write:scripts')
|
||||||
|
const mayStyle = hasPermission(actor, 'write:styles')
|
||||||
|
return {
|
||||||
|
jsLoad: mayScript ? (input.scriptJsLoad ?? existing.jsLoad ?? '') : (existing.jsLoad ?? ''),
|
||||||
|
jsUnload: mayScript
|
||||||
|
? (input.scriptJsUnload ?? existing.jsUnload ?? '')
|
||||||
|
: (existing.jsUnload ?? ''),
|
||||||
|
css: mayStyle ? (input.scriptCss ?? existing.css ?? '') : (existing.css ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a page's tree entry carries about it, so a folder listing needs no join.
|
||||||
|
*/
|
||||||
|
private treeMeta(page: any): Record<string, any> {
|
||||||
|
return {
|
||||||
|
authorId: page.authorId,
|
||||||
|
contentType: page.contentType,
|
||||||
|
creatorId: page.creatorId ?? page.authorId,
|
||||||
|
description: page.description ?? '',
|
||||||
|
editor: page.editor,
|
||||||
|
isBrowsable: page.isBrowsable,
|
||||||
|
ownerId: page.ownerId ?? page.authorId,
|
||||||
|
publishState: page.publishState,
|
||||||
|
publishEndDate: page.publishEndDate ?? null,
|
||||||
|
publishStartDate: page.publishStartDate ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pages = new Pages()
|
||||||
@ -0,0 +1,493 @@
|
|||||||
|
import * as cheerio from 'cheerio'
|
||||||
|
import sanitizeHtml from 'sanitize-html'
|
||||||
|
import { CustomError } from '../helpers/common.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering model
|
||||||
|
*
|
||||||
|
* Markdown becomes HTML in the browser, not here: the editor renders as you type, and what it shows
|
||||||
|
* in its preview is what gets sent up and stored. One renderer, one result — the preview cannot drift
|
||||||
|
* from the saved page because they are the same render.
|
||||||
|
*
|
||||||
|
* What this model does is everything that has to happen *after* that, and cannot be left to the
|
||||||
|
* client:
|
||||||
|
*
|
||||||
|
* - **Sanitizing.** The HTML arrived from a browser, so it is a user input like any other. What
|
||||||
|
* survives depends on what the author is allowed to do — scripts and styles are permissions.
|
||||||
|
* - **Normalizing.** The editor leaves scaffolding in its output (line markers for preview scroll
|
||||||
|
* sync) that has no business being stored, and headings arrive without the anchors a table of
|
||||||
|
* contents needs.
|
||||||
|
* - **Extracting.** The table of contents and the plain text the search index is built from are both
|
||||||
|
* derived from the final HTML, once it is settled.
|
||||||
|
*
|
||||||
|
* Re-rendering an existing page from its source — which the server needs when the content is there
|
||||||
|
* but the render is stale — goes back through the very same frontend pipeline, driven in a headless
|
||||||
|
* browser. See `renderContent`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A heading in the table of contents, shaped for the Quasar tree the page sidebar draws. */
|
||||||
|
export interface TocNode {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
children: TocNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostProcessResult {
|
||||||
|
/** The HTML to store and serve. */
|
||||||
|
render: string
|
||||||
|
/** The table of contents, derived from the headings. */
|
||||||
|
toc: TocNode[]
|
||||||
|
/** Plain text, for the search index. */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the author is allowed to put in a page, beyond ordinary content. */
|
||||||
|
export interface RenderPermissions {
|
||||||
|
/** `write:scripts` — may embed `<script>` and inline event handlers. */
|
||||||
|
scripts: boolean
|
||||||
|
/** `write:styles` — may embed `<style>` and inline `style` attributes. */
|
||||||
|
styles: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags and attributes a page may use whoever wrote it.
|
||||||
|
*
|
||||||
|
* Deliberately broad: this is a wiki, the markdown renderer is configured with `allowHTML` on by
|
||||||
|
* default, and authors are expected to reach for raw HTML. The line being drawn is not "what looks
|
||||||
|
* like a document" but "what can execute" — those are the permission-gated parts below.
|
||||||
|
*/
|
||||||
|
const BASE_ALLOWED_TAGS = [
|
||||||
|
...sanitizeHtml.defaults.allowedTags,
|
||||||
|
'abbr',
|
||||||
|
'audio',
|
||||||
|
'button',
|
||||||
|
'del',
|
||||||
|
'details',
|
||||||
|
'figcaption',
|
||||||
|
'figure',
|
||||||
|
'img',
|
||||||
|
'ins',
|
||||||
|
'kbd',
|
||||||
|
'mark',
|
||||||
|
'picture',
|
||||||
|
'section',
|
||||||
|
'source',
|
||||||
|
'sub',
|
||||||
|
'summary',
|
||||||
|
'sup',
|
||||||
|
'track',
|
||||||
|
'u',
|
||||||
|
'video',
|
||||||
|
// -> KaTeX renders to MathML alongside its HTML fallback
|
||||||
|
'annotation',
|
||||||
|
'math',
|
||||||
|
'menclose',
|
||||||
|
'mfrac',
|
||||||
|
'mi',
|
||||||
|
'mn',
|
||||||
|
'mo',
|
||||||
|
'mover',
|
||||||
|
'mpadded',
|
||||||
|
'mphantom',
|
||||||
|
'mroot',
|
||||||
|
'mrow',
|
||||||
|
'mspace',
|
||||||
|
'msqrt',
|
||||||
|
'mstyle',
|
||||||
|
'msub',
|
||||||
|
'msubsup',
|
||||||
|
'msup',
|
||||||
|
'mtable',
|
||||||
|
'mtd',
|
||||||
|
'mtext',
|
||||||
|
'mtr',
|
||||||
|
'munder',
|
||||||
|
'munderover',
|
||||||
|
'semantics',
|
||||||
|
// -> Inline SVG, which an author may well paste in. Structure and shapes only: `script`,
|
||||||
|
// `foreignObject` and the SMIL animation tags are all left out, since each of them is a way to
|
||||||
|
// get script or arbitrary markup back in through a picture.
|
||||||
|
'svg',
|
||||||
|
'circle',
|
||||||
|
'clipPath',
|
||||||
|
'defs',
|
||||||
|
'desc',
|
||||||
|
'ellipse',
|
||||||
|
'g',
|
||||||
|
'line',
|
||||||
|
'linearGradient',
|
||||||
|
'marker',
|
||||||
|
'mask',
|
||||||
|
'path',
|
||||||
|
'pattern',
|
||||||
|
'polygon',
|
||||||
|
'polyline',
|
||||||
|
'radialGradient',
|
||||||
|
'rect',
|
||||||
|
'stop',
|
||||||
|
'symbol',
|
||||||
|
'text',
|
||||||
|
'tspan',
|
||||||
|
'use'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Presentation attributes shared across the SVG subset above. None of them can execute. */
|
||||||
|
const SVG_ATTRIBUTES = [
|
||||||
|
'clip-path',
|
||||||
|
'clip-rule',
|
||||||
|
'cx',
|
||||||
|
'cy',
|
||||||
|
'd',
|
||||||
|
'fill',
|
||||||
|
'fill-opacity',
|
||||||
|
'fill-rule',
|
||||||
|
'height',
|
||||||
|
'href',
|
||||||
|
'mask',
|
||||||
|
'offset',
|
||||||
|
'opacity',
|
||||||
|
'points',
|
||||||
|
'preserveAspectRatio',
|
||||||
|
'r',
|
||||||
|
'rx',
|
||||||
|
'ry',
|
||||||
|
'stop-color',
|
||||||
|
'stop-opacity',
|
||||||
|
'stroke',
|
||||||
|
'stroke-dasharray',
|
||||||
|
'stroke-linecap',
|
||||||
|
'stroke-linejoin',
|
||||||
|
'stroke-opacity',
|
||||||
|
'stroke-width',
|
||||||
|
'transform',
|
||||||
|
'viewBox',
|
||||||
|
'width',
|
||||||
|
'x',
|
||||||
|
'x1',
|
||||||
|
'x2',
|
||||||
|
'y',
|
||||||
|
'y1',
|
||||||
|
'y2'
|
||||||
|
]
|
||||||
|
|
||||||
|
const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||||
|
// -> `style` is here rather than behind `write:styles` because the renderer itself produces it:
|
||||||
|
// KaTeX sizes and positions every piece of a formula with inline styles, and math would come
|
||||||
|
// out mangled for any author without the permission. The permission gates the `<style>` tag,
|
||||||
|
// which is where a page can restyle everything around it.
|
||||||
|
'*': ['id', 'class', 'style', 'title', 'dir', 'lang', 'aria-*', 'role', 'data-*'],
|
||||||
|
a: ['href', 'name', 'target', 'rel', 'download'],
|
||||||
|
audio: ['controls', 'loop', 'muted', 'preload', 'src'],
|
||||||
|
img: ['src', 'srcset', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||||
|
input: ['type', 'checked', 'disabled'],
|
||||||
|
ol: ['start', 'reversed', 'type'],
|
||||||
|
source: ['src', 'srcset', 'type', 'media'],
|
||||||
|
td: ['colspan', 'rowspan', 'align'],
|
||||||
|
th: ['colspan', 'rowspan', 'align', 'scope'],
|
||||||
|
track: ['src', 'kind', 'srclang', 'label', 'default'],
|
||||||
|
video: ['controls', 'loop', 'muted', 'poster', 'preload', 'src', 'width', 'height'],
|
||||||
|
// -> MathML carries its meaning in attributes, and none of them are executable
|
||||||
|
math: ['xmlns', 'display'],
|
||||||
|
annotation: ['encoding'],
|
||||||
|
mo: ['stretchy', 'fence', 'separator', 'lspace', 'rspace', 'minsize', 'maxsize'],
|
||||||
|
mspace: ['width', 'height', 'depth'],
|
||||||
|
mstyle: ['scriptlevel', 'displaystyle', 'mathcolor', 'mathvariant'],
|
||||||
|
mpadded: ['width', 'height', 'depth', 'lspace', 'voffset'],
|
||||||
|
mtable: ['columnalign', 'rowspacing', 'columnspacing', 'rowlines', 'columnlines'],
|
||||||
|
mtd: ['columnalign', 'rowspan', 'columnspan'],
|
||||||
|
svg: [...SVG_ATTRIBUTES, 'xmlns', 'xmlns:xlink'],
|
||||||
|
circle: SVG_ATTRIBUTES,
|
||||||
|
clipPath: SVG_ATTRIBUTES,
|
||||||
|
defs: SVG_ATTRIBUTES,
|
||||||
|
ellipse: SVG_ATTRIBUTES,
|
||||||
|
g: SVG_ATTRIBUTES,
|
||||||
|
line: SVG_ATTRIBUTES,
|
||||||
|
linearGradient: [...SVG_ATTRIBUTES, 'gradientUnits', 'gradientTransform'],
|
||||||
|
marker: [...SVG_ATTRIBUTES, 'markerWidth', 'markerHeight', 'orient', 'refX', 'refY'],
|
||||||
|
mask: [...SVG_ATTRIBUTES, 'maskUnits'],
|
||||||
|
path: SVG_ATTRIBUTES,
|
||||||
|
pattern: [...SVG_ATTRIBUTES, 'patternUnits'],
|
||||||
|
polygon: SVG_ATTRIBUTES,
|
||||||
|
polyline: SVG_ATTRIBUTES,
|
||||||
|
radialGradient: [...SVG_ATTRIBUTES, 'gradientUnits', 'gradientTransform', 'fx', 'fy'],
|
||||||
|
rect: SVG_ATTRIBUTES,
|
||||||
|
stop: SVG_ATTRIBUTES,
|
||||||
|
symbol: SVG_ATTRIBUTES,
|
||||||
|
text: [...SVG_ATTRIBUTES, 'dx', 'dy', 'text-anchor', 'font-size', 'font-family'],
|
||||||
|
tspan: [...SVG_ATTRIBUTES, 'dx', 'dy'],
|
||||||
|
use: SVG_ATTRIBUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which URL schemes may appear in a link or an embed.
|
||||||
|
*
|
||||||
|
* `javascript:` is absent, which is the point; `data:` is allowed only for images, where it is how a
|
||||||
|
* small inline graphic is written and where it cannot script.
|
||||||
|
*/
|
||||||
|
const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'ftp']
|
||||||
|
|
||||||
|
/** Attributes the editor adds for its own preview and that mean nothing in a stored page. */
|
||||||
|
const EDITOR_ARTIFACT_ATTRIBUTES = ['data-line']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a heading into an anchor fragment.
|
||||||
|
*
|
||||||
|
* Kept deliberately plain — lowercase, words joined by hyphens — because these end up in URLs that
|
||||||
|
* people copy and share, and because an existing link should keep working when the heading around it
|
||||||
|
* is edited in ways that do not change its words.
|
||||||
|
*/
|
||||||
|
export function slugifyHeading(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replaceAll(/[^\p{L}\p{N}\s-]/gu, '')
|
||||||
|
.replaceAll(/\s+/g, '-')
|
||||||
|
.replaceAll(/-{2,}/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 100) || 'section'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Rendering {
|
||||||
|
/**
|
||||||
|
* Clean up a render that came from a client, and pull out what is derived from it.
|
||||||
|
*
|
||||||
|
* @param html The HTML the editor produced
|
||||||
|
* @param permissions What the author may embed. Anything not granted is stripped rather than
|
||||||
|
* rejected: an author pasting a snippet with a tracking script should get their
|
||||||
|
* page saved without it, not an error they cannot act on.
|
||||||
|
*/
|
||||||
|
postProcess(html: string, permissions: RenderPermissions): PostProcessResult {
|
||||||
|
const clean = this.sanitize(html ?? '', permissions)
|
||||||
|
|
||||||
|
const $ = cheerio.load(clean, null, false)
|
||||||
|
|
||||||
|
this.stripEditorArtifacts($)
|
||||||
|
const toc = this.anchorHeadings($)
|
||||||
|
|
||||||
|
return {
|
||||||
|
render: $.html(),
|
||||||
|
toc,
|
||||||
|
text: this.extractText($)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip everything the author is not allowed to embed.
|
||||||
|
*/
|
||||||
|
private sanitize(html: string, permissions: RenderPermissions): string {
|
||||||
|
const allowedTags = [...BASE_ALLOWED_TAGS]
|
||||||
|
const allowedAttributes: Record<string, string[]> = {
|
||||||
|
...BASE_ALLOWED_ATTRIBUTES,
|
||||||
|
'*': [...BASE_ALLOWED_ATTRIBUTES['*']]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.styles) {
|
||||||
|
allowedTags.push('style')
|
||||||
|
}
|
||||||
|
if (permissions.scripts) {
|
||||||
|
allowedTags.push('script')
|
||||||
|
// -> Inline handlers are only meaningful to someone who may also write a script tag
|
||||||
|
allowedAttributes['*'].push('on*')
|
||||||
|
allowedAttributes.script = ['src', 'type', 'async', 'defer']
|
||||||
|
// -> An iframe runs someone else's page inside this one, which is the same trust decision as
|
||||||
|
// running a script, and it is how an author embeds a video or a live example
|
||||||
|
allowedTags.push('iframe')
|
||||||
|
allowedAttributes.iframe = [
|
||||||
|
'src',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'allow',
|
||||||
|
'allowfullscreen',
|
||||||
|
'loading',
|
||||||
|
'referrerpolicy',
|
||||||
|
'sandbox'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeHtml(html, {
|
||||||
|
allowedTags,
|
||||||
|
allowedAttributes,
|
||||||
|
// -> `script` and `style` in the allow list are what `write:scripts` and `write:styles` mean:
|
||||||
|
// the library warns about them on every call, and the warning is the thing to silence, not
|
||||||
|
// the permission
|
||||||
|
allowVulnerableTags: permissions.scripts || permissions.styles,
|
||||||
|
allowedSchemes: ALLOWED_SCHEMES,
|
||||||
|
allowedSchemesByTag: {
|
||||||
|
img: [...ALLOWED_SCHEMES, 'data']
|
||||||
|
},
|
||||||
|
// -> A protocol-relative URL inherits the page's scheme, which is fine and common in embeds
|
||||||
|
allowProtocolRelative: true,
|
||||||
|
// -> Applies only to tags that were dropped: without it, the body of a rejected `<script>`
|
||||||
|
// would come back out as visible page text
|
||||||
|
nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript'],
|
||||||
|
parser: {
|
||||||
|
// -> SVG and MathML have case-sensitive attribute names (`viewBox`, `preserveAspectRatio`),
|
||||||
|
// which lowercasing would quietly break. Tags stay lowercased, so `<SCRIPT>` is still
|
||||||
|
// matched and dropped.
|
||||||
|
lowerCaseAttributeNames: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the markers the editor injects so its preview pane can follow the cursor.
|
||||||
|
*/
|
||||||
|
private stripEditorArtifacts($: cheerio.CheerioAPI): void {
|
||||||
|
for (const attribute of EDITOR_ARTIFACT_ATTRIBUTES) {
|
||||||
|
$(`[${attribute}]`).removeAttr(attribute)
|
||||||
|
}
|
||||||
|
// -> The `line` class rides along with `data-line` and is equally meaningless once stored
|
||||||
|
$('.line').each((_, el) => {
|
||||||
|
const remaining = ($(el).attr('class') ?? '').split(/\s+/).filter((c) => c && c !== 'line')
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
$(el).attr('class', remaining.join(' '))
|
||||||
|
} else {
|
||||||
|
$(el).removeAttr('class')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every heading an id and build the table of contents out of them.
|
||||||
|
*
|
||||||
|
* The markdown renderer does not emit heading anchors, so this is where a page becomes deep
|
||||||
|
* linkable — and the ids have to exist before the contents tree can point at them.
|
||||||
|
*/
|
||||||
|
private anchorHeadings($: cheerio.CheerioAPI): TocNode[] {
|
||||||
|
const used = new Map<string, number>()
|
||||||
|
const flat: { level: number; node: TocNode }[] = []
|
||||||
|
|
||||||
|
$('h1, h2, h3, h4, h5, h6').each((_, el) => {
|
||||||
|
const heading = $(el)
|
||||||
|
const label = heading.text().trim()
|
||||||
|
let key = heading.attr('id') || slugifyHeading(label)
|
||||||
|
|
||||||
|
// -> Two headings can legitimately read the same; the second one becomes `-1`, as anchors
|
||||||
|
// generally do, so that both remain addressable
|
||||||
|
const seen = used.get(key) ?? 0
|
||||||
|
used.set(key, seen + 1)
|
||||||
|
if (seen > 0) {
|
||||||
|
key = `${key}-${seen}`
|
||||||
|
}
|
||||||
|
|
||||||
|
heading.attr('id', key)
|
||||||
|
flat.push({
|
||||||
|
level: Number.parseInt(el.tagName.slice(1), 10),
|
||||||
|
node: { key: `#${key}`, label, children: [] }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.nestHeadings(flat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a flat run of headings into the nested tree the sidebar renders.
|
||||||
|
*
|
||||||
|
* Levels are treated as relative rather than absolute: a page whose headings start at `h2`, or that
|
||||||
|
* skips from `h2` to `h4`, still produces a sensible tree instead of an empty top level.
|
||||||
|
*/
|
||||||
|
private nestHeadings(flat: { level: number; node: TocNode }[]): TocNode[] {
|
||||||
|
const root: TocNode[] = []
|
||||||
|
const stack: { level: number; node: TocNode }[] = []
|
||||||
|
|
||||||
|
for (const entry of flat) {
|
||||||
|
while (stack.length > 0 && stack[stack.length - 1].level >= entry.level) {
|
||||||
|
stack.pop()
|
||||||
|
}
|
||||||
|
if (stack.length > 0) {
|
||||||
|
stack[stack.length - 1].node.children.push(entry.node)
|
||||||
|
} else {
|
||||||
|
root.push(entry.node)
|
||||||
|
}
|
||||||
|
stack.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page as plain text, which is what the search index is built from.
|
||||||
|
*
|
||||||
|
* Works on a copy: scripts and styles read as text but are not prose, and a page carrying them
|
||||||
|
* would otherwise turn up in results for whatever its code happens to mention.
|
||||||
|
*/
|
||||||
|
private extractText($: cheerio.CheerioAPI): string {
|
||||||
|
const $copy = cheerio.load($.html(), null, false)
|
||||||
|
$copy('script, style').remove()
|
||||||
|
return $copy.root().text().replaceAll(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render content to HTML the way the editor would, in a headless browser.
|
||||||
|
*
|
||||||
|
* The markdown pipeline lives in the frontend and stays there — this drives it rather than
|
||||||
|
* reimplementing it, so a page re-rendered by the server comes out identical to one saved from the
|
||||||
|
* editor. That costs a browser, which is why it is reserved for an explicit re-render rather than
|
||||||
|
* used on every save.
|
||||||
|
*
|
||||||
|
* Puppeteer is an extension, and one that is not installed by default. When it is missing this says
|
||||||
|
* so plainly: re-rendering is the only thing that needs it, and everything else keeps working.
|
||||||
|
*/
|
||||||
|
async renderContent(
|
||||||
|
content: string,
|
||||||
|
{ editor, config }: { editor: string; config: Record<string, any> }
|
||||||
|
): Promise<string> {
|
||||||
|
if (editor !== 'markdown') {
|
||||||
|
throw new CustomError(
|
||||||
|
'renderUnsupportedEditor',
|
||||||
|
`Server-side rendering is not implemented for the ${editor} editor.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = WIKI.models.extensions.getDefinition('puppeteer')
|
||||||
|
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
|
||||||
|
throw new CustomError(
|
||||||
|
'renderPuppeteerMissing',
|
||||||
|
'Re-rendering a page on the server needs the Puppeteer extension, which is not installed.',
|
||||||
|
503
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Held in a variable because Puppeteer is not a declared dependency: it is an extension the
|
||||||
|
// operator installs, so a literal import would not typecheck
|
||||||
|
const specifier = 'puppeteer'
|
||||||
|
let puppeteer: any
|
||||||
|
try {
|
||||||
|
;({ default: puppeteer } = await import(specifier))
|
||||||
|
} catch (err: any) {
|
||||||
|
WIKI.models.extensions.noteLoadFailure(specifier)
|
||||||
|
throw new CustomError(
|
||||||
|
'renderPuppeteerMissing',
|
||||||
|
`Could not load the Puppeteer extension: ${err.message}`,
|
||||||
|
503
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage()
|
||||||
|
// -> A shell page whose only job is to load the frontend's renderer bundle. It is served by this
|
||||||
|
// instance, so the bundle it loads is the one this instance's editor uses.
|
||||||
|
await page.goto(`http://127.0.0.1:${WIKI.config.port}/_render`, {
|
||||||
|
waitUntil: 'networkidle0'
|
||||||
|
})
|
||||||
|
await page.waitForFunction('window.__wikiRenderReady === true', { timeout: 30000 })
|
||||||
|
// -> This callback is serialized and runs in the browser, where `globalThis` is the window the
|
||||||
|
// renderer bundle attached itself to
|
||||||
|
return await page.evaluate(
|
||||||
|
(src: string, cfg: Record<string, any>) => (globalThis as any).__wikiRender(src, cfg),
|
||||||
|
content,
|
||||||
|
config
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rendering = new Rendering()
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
tag: string
|
||||||
|
usageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags
|
||||||
|
*
|
||||||
|
* A tag is not a row anybody creates: it exists because a page carries it, in `pages.tags`. The list
|
||||||
|
* is therefore derived rather than stored, which is what keeps it from drifting out of step with the
|
||||||
|
* pages after an edit, a delete or a restore.
|
||||||
|
*
|
||||||
|
* NOTE: the `tags` table in the schema is a leftover of an earlier design and is never written to.
|
||||||
|
* Reading from it here would answer every request with an empty list.
|
||||||
|
*/
|
||||||
|
class Tags {
|
||||||
|
/**
|
||||||
|
* Every tag used by a page of this site, most used first
|
||||||
|
*
|
||||||
|
* @param siteId Site the pages belong to
|
||||||
|
* @param limit Ceiling on how many distinct tags come back, most used first
|
||||||
|
*/
|
||||||
|
async getTags(siteId: string, { limit = 1000 }: { limit?: number } = {}): Promise<Tag[]> {
|
||||||
|
const result = await WIKI.db.execute(sql`
|
||||||
|
SELECT tag, COUNT(*)::int AS "usageCount"
|
||||||
|
FROM pages, unnest(tags) AS tag
|
||||||
|
WHERE "siteId" = ${siteId}
|
||||||
|
GROUP BY tag
|
||||||
|
ORDER BY COUNT(*) DESC, tag ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`)
|
||||||
|
return ((result.rows ?? result) as any[]).map((row) => ({
|
||||||
|
tag: row.tag as string,
|
||||||
|
usageCount: row.usageCount as number
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tags = new Tags()
|
||||||
@ -0,0 +1,946 @@
|
|||||||
|
import { and, asc, desc, eq, inArray, ne, or, sql, type SQL } from 'drizzle-orm'
|
||||||
|
import { tree as treeTable } from '../db/schema.ts'
|
||||||
|
import { CustomError, decodeTreePath, encodeTreePath, generateHash } from '../helpers/common.ts'
|
||||||
|
|
||||||
|
/** What a tree entry can be. Mirrors the `treeType` enum in the schema. */
|
||||||
|
export type TreeItemType = 'folder' | 'page' | 'asset'
|
||||||
|
|
||||||
|
/** The fields a tree listing can be sorted on. */
|
||||||
|
export const TREE_ORDER_BY = ['createdAt', 'fileName', 'title', 'updatedAt'] as const
|
||||||
|
|
||||||
|
export type TreeOrderBy = (typeof TREE_ORDER_BY)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tree entry as exposed by the API.
|
||||||
|
*
|
||||||
|
* One shape for all three kinds rather than three: a folder listing interleaves them, and the type
|
||||||
|
* field is what tells them apart. The kind-specific fields are absent on the kinds they do not apply
|
||||||
|
* to.
|
||||||
|
*/
|
||||||
|
export interface TreeItem {
|
||||||
|
id: string
|
||||||
|
type: TreeItemType
|
||||||
|
/** How many folders deep the entry sits, 0 being the root. */
|
||||||
|
depth: number
|
||||||
|
/** Slash-separated, without a leading or trailing slash. Empty at the root. */
|
||||||
|
folderPath: string
|
||||||
|
fileName: string
|
||||||
|
title: string
|
||||||
|
tags: string[]
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
/** Folders only — how many entries the folder holds. */
|
||||||
|
childrenCount?: number
|
||||||
|
/** Folders only — whether this folder is a parent of the one being listed, not a child of it. */
|
||||||
|
isAncestor?: boolean
|
||||||
|
/** Assets only. */
|
||||||
|
fileSize?: number
|
||||||
|
fileExt?: string
|
||||||
|
mimeType?: string
|
||||||
|
/** Pages only. */
|
||||||
|
editor?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A raw `tree` row, as the model passes it around internally. */
|
||||||
|
export interface TreeRow {
|
||||||
|
id: string
|
||||||
|
folderPath: string | null
|
||||||
|
fileName: string
|
||||||
|
type: TreeItemType
|
||||||
|
locale: string
|
||||||
|
title: string
|
||||||
|
tags: string[]
|
||||||
|
meta: Record<string, any>
|
||||||
|
siteId: string
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folders are addressed by URL, so their file name is restricted to what reads well in one. */
|
||||||
|
const rePathName = /^[a-z0-9-]+$/
|
||||||
|
const reTitle = /^[^<>"]+$/
|
||||||
|
|
||||||
|
/** Ceiling on how many entries one listing returns, and how deep it may recurse. */
|
||||||
|
const MAX_LIMIT = 1000
|
||||||
|
const MAX_DEPTH = 10
|
||||||
|
|
||||||
|
/** How many `name-1`, `name-2`… variants an upload will try before giving up on the name. */
|
||||||
|
const MAX_NAME_ATTEMPTS = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ltree path of a folder's *contents*, i.e. the value its children carry in `folderPath`.
|
||||||
|
*/
|
||||||
|
function childPathOf(folder: { folderPath?: string | null; fileName: string }): string {
|
||||||
|
return folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an ltree path into the (folderPath, fileName) pair that addresses the entry itself.
|
||||||
|
*/
|
||||||
|
function splitPath(path: string): { folderPath: string; fileName: string } {
|
||||||
|
const parts = path.split('.')
|
||||||
|
return {
|
||||||
|
folderPath: parts.slice(0, -1).join('.'),
|
||||||
|
fileName: parts.at(-1) ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a row into the shape the API returns.
|
||||||
|
*/
|
||||||
|
function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
|
||||||
|
const folderPath = row.folderPath ?? ''
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
depth,
|
||||||
|
folderPath: decodeTreePath(folderPath) ?? '',
|
||||||
|
fileName: row.fileName,
|
||||||
|
title: row.title,
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
...(row.type === 'folder' && {
|
||||||
|
childrenCount: row.meta?.children ?? 0,
|
||||||
|
// -> Shorter than the folder being listed means it sits above it, so it came from
|
||||||
|
// `includeAncestors` / `includeRootFolders` rather than from the listing itself
|
||||||
|
isAncestor: folderPath.length < parentPath.length
|
||||||
|
}),
|
||||||
|
...(row.type === 'asset' && {
|
||||||
|
fileSize: row.meta?.fileSize ?? 0,
|
||||||
|
fileExt: row.meta?.fileExt ?? '',
|
||||||
|
mimeType: row.meta?.mimeType ?? ''
|
||||||
|
}),
|
||||||
|
...(row.type === 'page' && {
|
||||||
|
editor: row.meta?.editor ?? '',
|
||||||
|
description: row.meta?.description ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree model
|
||||||
|
*
|
||||||
|
* The tree is the single index of everything addressable in a site — folders, pages and assets alike
|
||||||
|
* — keyed by an ltree `folderPath`. Pages and assets keep their own rows elsewhere and join back on
|
||||||
|
* the same ID; the tree row is what gives them a place and a name.
|
||||||
|
*
|
||||||
|
* Paths are slashes on the way in and out (`foo/bar`) and dots inside the database (`foo.bar`), which
|
||||||
|
* is what `encodeTreePath` / `decodeTreePath` convert between. Nothing outside this model should have
|
||||||
|
* to know about the dotted form.
|
||||||
|
*/
|
||||||
|
class Tree {
|
||||||
|
/**
|
||||||
|
* List the contents of a folder.
|
||||||
|
*
|
||||||
|
* @param parentId UUID of the folder to list. Takes precedence over `parentPath`.
|
||||||
|
* @param parentPath Slash-separated path of the folder to list. The site root when both are absent.
|
||||||
|
* @param depth How many levels below the folder to include. 0, the default, is the folder itself.
|
||||||
|
* @param includeAncestors Also return every folder between the root and the one being listed, so a
|
||||||
|
* caller opening a deep folder gets the branch it hangs off in one request.
|
||||||
|
* @param includeRootFolders Also return every folder at the root, for the same reason.
|
||||||
|
*/
|
||||||
|
async getTree({
|
||||||
|
siteId,
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
locale,
|
||||||
|
types,
|
||||||
|
tags,
|
||||||
|
limit = MAX_LIMIT,
|
||||||
|
offset = 0,
|
||||||
|
orderBy = 'title',
|
||||||
|
orderByDirection = 'asc',
|
||||||
|
depth = 0,
|
||||||
|
includeAncestors = false,
|
||||||
|
includeRootFolders = false
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
locale?: string | null
|
||||||
|
types?: TreeItemType[] | null
|
||||||
|
tags?: string[] | null
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
orderBy?: TreeOrderBy
|
||||||
|
orderByDirection?: 'asc' | 'desc'
|
||||||
|
depth?: number
|
||||||
|
includeAncestors?: boolean
|
||||||
|
includeRootFolders?: boolean
|
||||||
|
}): Promise<TreeItem[]> {
|
||||||
|
if (offset < 0) {
|
||||||
|
throw new CustomError('treeInvalidOffset', 'The offset cannot be negative.')
|
||||||
|
}
|
||||||
|
if (limit < 1 || limit > MAX_LIMIT) {
|
||||||
|
throw new CustomError('treeInvalidLimit', `The limit must be between 1 and ${MAX_LIMIT}.`)
|
||||||
|
}
|
||||||
|
if (depth < 0 || depth > MAX_DEPTH) {
|
||||||
|
throw new CustomError('treeInvalidDepth', `The depth must be between 0 and ${MAX_DEPTH}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Resolve what to list into the ltree path its children carry
|
||||||
|
let path = ''
|
||||||
|
if (parentId) {
|
||||||
|
const parent = await this.getFolderById(parentId)
|
||||||
|
if (parent) {
|
||||||
|
path = childPathOf(parent)
|
||||||
|
}
|
||||||
|
} else if (parentPath) {
|
||||||
|
path = encodeTreePath(parentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels = depth > 0 ? `*{,${depth}}` : '*{0}'
|
||||||
|
const pathQuery = path ? `${path}.${levels}` : levels
|
||||||
|
|
||||||
|
const locations: SQL[] = [sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`]
|
||||||
|
if (includeAncestors && path) {
|
||||||
|
// -> Each iteration drops one level off the end, walking the branch back up to the root
|
||||||
|
const parts = path.split('.')
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
locations.push(
|
||||||
|
and(
|
||||||
|
eq(treeTable.folderPath, parts.slice(0, parts.length - 1 - i).join('.')),
|
||||||
|
eq(treeTable.fileName, parts[parts.length - 1 - i]),
|
||||||
|
eq(treeTable.type, 'folder')
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (includeRootFolders) {
|
||||||
|
locations.push(and(eq(treeTable.folderPath, ''), eq(treeTable.type, 'folder'))!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: (SQL | undefined)[] = [eq(treeTable.siteId, siteId), or(...locations)]
|
||||||
|
if (locale) {
|
||||||
|
conditions.push(eq(treeTable.locale, locale))
|
||||||
|
}
|
||||||
|
if (types && types.length > 0) {
|
||||||
|
conditions.push(inArray(treeTable.type, types))
|
||||||
|
}
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
conditions.push(sql`${treeTable.tags} @> ${tags}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = orderByDirection === 'desc' ? desc : asc
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({
|
||||||
|
row: treeTable,
|
||||||
|
depth: sql<number>`nlevel(${treeTable.folderPath})`.mapWith(Number)
|
||||||
|
})
|
||||||
|
.from(treeTable)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(sql`nlevel(${treeTable.folderPath})`), direction(treeTable[orderBy]))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
|
||||||
|
return rows.map(({ row, depth: rowDepth }) => toTreeItem(row as TreeRow, rowDepth, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single tree row by ID, or null if there is no such row
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<TreeRow | null> {
|
||||||
|
const results = await WIKI.db.select().from(treeTable).where(eq(treeTable.id, id)).limit(1)
|
||||||
|
return (results[0] as TreeRow) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single folder by ID, or null if the ID is not a folder
|
||||||
|
*/
|
||||||
|
async getFolderById(id: string): Promise<TreeRow | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select()
|
||||||
|
.from(treeTable)
|
||||||
|
.where(and(eq(treeTable.id, id), eq(treeTable.type, 'folder')))
|
||||||
|
.limit(1)
|
||||||
|
return (results[0] as TreeRow) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a folder, either by ID or by path.
|
||||||
|
*
|
||||||
|
* @param createIfMissing Create the folder, and any ancestor it needs, when the path has none. Only
|
||||||
|
* applies when resolving by path — an ID that matches nothing is an error
|
||||||
|
* either way.
|
||||||
|
*/
|
||||||
|
async getFolder({
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
createIfMissing = false
|
||||||
|
}: {
|
||||||
|
id?: string | null
|
||||||
|
path?: string | null
|
||||||
|
locale?: string
|
||||||
|
siteId?: string
|
||||||
|
createIfMissing?: boolean
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
if (id) {
|
||||||
|
const folder = await this.getFolderById(id)
|
||||||
|
if (!folder) {
|
||||||
|
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
|
||||||
|
}
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
const { folderPath, fileName } = splitPath(encodeTreePath(path))
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select()
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(treeTable.siteId, siteId!),
|
||||||
|
eq(treeTable.locale, locale!),
|
||||||
|
eq(treeTable.folderPath, folderPath),
|
||||||
|
eq(treeTable.fileName, fileName),
|
||||||
|
eq(treeTable.type, 'folder')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (results[0]) {
|
||||||
|
return results[0] as TreeRow
|
||||||
|
}
|
||||||
|
if (!createIfMissing) {
|
||||||
|
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
|
||||||
|
}
|
||||||
|
return this.createFolder({
|
||||||
|
parentPath: folderPath,
|
||||||
|
pathName: fileName,
|
||||||
|
title: fileName,
|
||||||
|
locale: locale!,
|
||||||
|
siteId: siteId!
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a folder, and any of its ancestors that do not exist yet.
|
||||||
|
*
|
||||||
|
* @param parentId UUID of the folder to create it in. Takes precedence over `parentPath`.
|
||||||
|
* @param parentPath Slash-separated path of the folder to create it in. The root when both are absent.
|
||||||
|
* @param pathName The folder's own path segment, lowercase and URL friendly.
|
||||||
|
*/
|
||||||
|
async createFolder({
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
pathName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId
|
||||||
|
}: {
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
pathName: string
|
||||||
|
title: string
|
||||||
|
locale: string
|
||||||
|
siteId: string
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
if (!rePathName.test(pathName)) {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeInvalidPath',
|
||||||
|
'A folder path name may only contain lowercase alphanumeric and hyphen characters.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!reTitle.test(title)) {
|
||||||
|
throw new CustomError('treeInvalidTitle', 'The folder title contains invalid characters.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Resolve where it goes, as the ltree path the new folder will carry
|
||||||
|
let path = encodeTreePath(parentPath)
|
||||||
|
let effectiveLocale = locale
|
||||||
|
if (parentId) {
|
||||||
|
const parent = await this.getFolderById(parentId)
|
||||||
|
if (!parent) {
|
||||||
|
throw new CustomError('treeInvalidParent', 'The parent folder does not exist.', 404)
|
||||||
|
}
|
||||||
|
path = childPathOf(parent)
|
||||||
|
// -> A folder cannot be in a different locale than the one holding it
|
||||||
|
effectiveLocale = parent.locale
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await WIKI.db
|
||||||
|
.select({ id: treeTable.id })
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(treeTable.siteId, siteId),
|
||||||
|
eq(treeTable.locale, effectiveLocale),
|
||||||
|
eq(treeTable.folderPath, path),
|
||||||
|
eq(treeTable.fileName, pathName),
|
||||||
|
eq(treeTable.type, 'folder')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeFolderDuplicate',
|
||||||
|
'A folder with this path name already exists.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> A path can be created from the middle out — by an upload into a folder nobody made yet, or by
|
||||||
|
// a rename that left a gap — so every level above the new folder is filled in first
|
||||||
|
if (path) {
|
||||||
|
const parts = path.split('.')
|
||||||
|
const expected = parts.map((_, i) => ({
|
||||||
|
folderPath: parts.slice(0, i).join('.'),
|
||||||
|
fileName: parts[i]
|
||||||
|
}))
|
||||||
|
const found = await WIKI.db
|
||||||
|
.select({ folderPath: treeTable.folderPath, fileName: treeTable.fileName })
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(treeTable.siteId, siteId),
|
||||||
|
eq(treeTable.locale, effectiveLocale),
|
||||||
|
eq(treeTable.type, 'folder'),
|
||||||
|
or(
|
||||||
|
...expected.map(
|
||||||
|
(ancestor) =>
|
||||||
|
and(
|
||||||
|
eq(treeTable.folderPath, ancestor.folderPath),
|
||||||
|
eq(treeTable.fileName, ancestor.fileName)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const missing = expected.filter(
|
||||||
|
(ancestor) =>
|
||||||
|
!found.some(
|
||||||
|
(row) =>
|
||||||
|
(row.folderPath ?? '') === ancestor.folderPath && row.fileName === ancestor.fileName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// -> Shallowest first, so that each one's own parent is already there to be counted against
|
||||||
|
for (const ancestor of missing) {
|
||||||
|
WIKI.logger.debug(
|
||||||
|
`Creating missing parent folder ${ancestor.fileName} at path /${decodeTreePath(ancestor.folderPath)}...`
|
||||||
|
)
|
||||||
|
const ancestorFullPath = ancestor.folderPath
|
||||||
|
? `${decodeTreePath(ancestor.folderPath)}/${ancestor.fileName}`
|
||||||
|
: ancestor.fileName
|
||||||
|
await WIKI.db.insert(treeTable).values({
|
||||||
|
folderPath: ancestor.folderPath,
|
||||||
|
fileName: ancestor.fileName,
|
||||||
|
type: 'folder',
|
||||||
|
title: ancestor.fileName,
|
||||||
|
hash: generateHash(ancestorFullPath),
|
||||||
|
locale: effectiveLocale,
|
||||||
|
siteId,
|
||||||
|
meta: { children: 0 }
|
||||||
|
})
|
||||||
|
await this.countTowardsFolderAt(siteId, ancestor.folderPath, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path ? `${decodeTreePath(path)}/${pathName}` : pathName
|
||||||
|
const inserted = await WIKI.db
|
||||||
|
.insert(treeTable)
|
||||||
|
.values({
|
||||||
|
folderPath: path,
|
||||||
|
fileName: pathName,
|
||||||
|
type: 'folder',
|
||||||
|
title,
|
||||||
|
hash: generateHash(fullPath),
|
||||||
|
locale: effectiveLocale,
|
||||||
|
siteId,
|
||||||
|
meta: { children: 0 }
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
await this.countTowardsFolderAt(siteId, path, 1)
|
||||||
|
|
||||||
|
WIKI.logger.debug(`Created folder ${inserted[0].id} successfully.`)
|
||||||
|
return inserted[0] as TreeRow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a folder, moving everything under it along with it.
|
||||||
|
*
|
||||||
|
* @param pathName The new path segment. Unchanged from the current one when only the title differs,
|
||||||
|
* which leaves every descendant's path untouched.
|
||||||
|
*/
|
||||||
|
async renameFolder({
|
||||||
|
folderId,
|
||||||
|
pathName,
|
||||||
|
title
|
||||||
|
}: {
|
||||||
|
folderId: string
|
||||||
|
pathName: string
|
||||||
|
title: string
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
const folder = await this.getFolderById(folderId)
|
||||||
|
if (!folder) {
|
||||||
|
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
|
||||||
|
}
|
||||||
|
if (!rePathName.test(pathName)) {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeInvalidPath',
|
||||||
|
'A folder path name may only contain lowercase alphanumeric and hyphen characters.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!reTitle.test(title)) {
|
||||||
|
throw new CustomError('treeInvalidTitle', 'The folder title contains invalid characters.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === folder.fileName) {
|
||||||
|
const updated = await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ title, updatedAt: sql`now()` })
|
||||||
|
.where(eq(treeTable.id, folder.id))
|
||||||
|
.returning()
|
||||||
|
return updated[0] as TreeRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await WIKI.db
|
||||||
|
.select({ id: treeTable.id })
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
ne(treeTable.id, folder.id),
|
||||||
|
eq(treeTable.siteId, folder.siteId),
|
||||||
|
eq(treeTable.locale, folder.locale),
|
||||||
|
eq(treeTable.folderPath, folder.folderPath ?? ''),
|
||||||
|
eq(treeTable.fileName, pathName),
|
||||||
|
eq(treeTable.type, 'folder')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeFolderDuplicate',
|
||||||
|
'A folder with this path name already exists.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPath = childPathOf(folder)
|
||||||
|
const newPath = folder.folderPath ? `${folder.folderPath}.${pathName}` : pathName
|
||||||
|
|
||||||
|
WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`)
|
||||||
|
|
||||||
|
// -> Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep
|
||||||
|
// whatever they had below it
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ folderPath: newPath })
|
||||||
|
.where(and(eq(treeTable.siteId, folder.siteId), eq(treeTable.folderPath, oldPath)))
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({
|
||||||
|
folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${newPath}::ltree))`
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${oldPath}::ltree`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fullPath = folder.folderPath
|
||||||
|
? `${decodeTreePath(folder.folderPath)}/${pathName}`
|
||||||
|
: pathName
|
||||||
|
const updated = await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ fileName: pathName, title, hash: generateHash(fullPath), updatedAt: sql`now()` })
|
||||||
|
.where(eq(treeTable.id, folder.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
await this.refreshHashes(folder.siteId, newPath)
|
||||||
|
|
||||||
|
WIKI.logger.debug(`Renamed folder ${folder.id} successfully.`)
|
||||||
|
return updated[0] as TreeRow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute the path hash of everything at or below a folder.
|
||||||
|
*
|
||||||
|
* The hash is how an entry is found by its path, so moving a branch without redoing them would
|
||||||
|
* leave every page and asset under it unreachable by URL. It is a SHA-1 of the full path, which
|
||||||
|
* postgres has no function for, so each row is rewritten from here.
|
||||||
|
*/
|
||||||
|
private async refreshHashes(siteId: string, path: string): Promise<void> {
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({
|
||||||
|
id: treeTable.id,
|
||||||
|
folderPath: treeTable.folderPath,
|
||||||
|
fileName: treeTable.fileName
|
||||||
|
})
|
||||||
|
.from(treeTable)
|
||||||
|
.where(and(eq(treeTable.siteId, siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`))
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const folderPath = decodeTreePath(row.folderPath ?? '')
|
||||||
|
const fullPath = folderPath ? `${folderPath}/${row.fileName}` : row.fileName
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({ hash: generateHash(fullPath) })
|
||||||
|
.where(eq(treeTable.id, row.id))
|
||||||
|
}
|
||||||
|
if (rows.length > 0) {
|
||||||
|
WIKI.logger.debug(`Refreshed the path hash of ${rows.length} moved entrie(s).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a folder and everything under it.
|
||||||
|
*
|
||||||
|
* @returns The IDs of the deleted pages and assets, for the caller to clean up after
|
||||||
|
*/
|
||||||
|
async deleteFolder(folderId: string): Promise<{ pages: string[]; assets: string[] }> {
|
||||||
|
const folder = await this.getFolderById(folderId)
|
||||||
|
if (!folder) {
|
||||||
|
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
|
||||||
|
}
|
||||||
|
const path = childPathOf(folder)
|
||||||
|
WIKI.logger.debug(`Deleting folder ${folder.id} at path ${path}...`)
|
||||||
|
|
||||||
|
// -> `<@` is "at or below", and the folder itself is not under its own child path, so this takes
|
||||||
|
// the descendants and leaves the row that owns them
|
||||||
|
const deleted = await WIKI.db
|
||||||
|
.delete(treeTable)
|
||||||
|
.where(
|
||||||
|
and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`)
|
||||||
|
)
|
||||||
|
.returning({ id: treeTable.id, type: treeTable.type })
|
||||||
|
|
||||||
|
await WIKI.db.delete(treeTable).where(eq(treeTable.id, folder.id))
|
||||||
|
|
||||||
|
// -> Any of them may have owned a sidebar menu keyed by its own id, the folder included
|
||||||
|
await WIKI.models.navigation.deleteNavForEntries([...deleted.map((n) => n.id), folder.id])
|
||||||
|
|
||||||
|
await this.countTowardsFolderAt(folder.siteId, folder.folderPath ?? '', -1)
|
||||||
|
|
||||||
|
WIKI.logger.debug(`Deleted folder ${folder.id} and ${deleted.length} descendant(s).`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: deleted.filter((n) => n.type === 'page').map((n) => n.id),
|
||||||
|
assets: deleted.filter((n) => n.type === 'asset').map((n) => n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a page entry to the tree.
|
||||||
|
*
|
||||||
|
* @param parentId UUID of the folder to add it to. Takes precedence over `parentPath`.
|
||||||
|
* @param parentPath Slash-separated path of the folder to add it to, created if it does not exist.
|
||||||
|
*/
|
||||||
|
async addPage({
|
||||||
|
id,
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags = [],
|
||||||
|
meta = {}
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
fileName: string
|
||||||
|
title: string
|
||||||
|
locale: string
|
||||||
|
siteId: string
|
||||||
|
tags?: string[]
|
||||||
|
meta?: Record<string, any>
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
return this.addEntry({
|
||||||
|
id,
|
||||||
|
type: 'page',
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags,
|
||||||
|
meta,
|
||||||
|
// -> Pages inherit the site's navigation until something says otherwise
|
||||||
|
navigationId: siteId,
|
||||||
|
// -> A page's file name is its URL, chosen deliberately by whoever wrote it, so a clash is
|
||||||
|
// something to report rather than something to work around
|
||||||
|
onConflict: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an asset entry to the tree.
|
||||||
|
*
|
||||||
|
* @param parentId UUID of the folder to add it to. Takes precedence over `parentPath`.
|
||||||
|
* @param parentPath Slash-separated path of the folder to add it to, created if it does not exist.
|
||||||
|
*/
|
||||||
|
async addAsset({
|
||||||
|
id,
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags = [],
|
||||||
|
meta = {}
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
fileName: string
|
||||||
|
title: string
|
||||||
|
locale: string
|
||||||
|
siteId: string
|
||||||
|
tags?: string[]
|
||||||
|
meta?: Record<string, any>
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
return this.addEntry({
|
||||||
|
id,
|
||||||
|
type: 'asset',
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags,
|
||||||
|
meta,
|
||||||
|
// -> Uploading a file already in the folder takes the next free `name-1.ext`, rather than
|
||||||
|
// failing on something the uploader did not choose and cannot see
|
||||||
|
onConflict: 'suffix'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a page or asset entry within its folder.
|
||||||
|
*
|
||||||
|
* @returns The updated row, or null if there is no such entry
|
||||||
|
*/
|
||||||
|
async renameEntry({
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
title
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
title?: string
|
||||||
|
}): Promise<TreeRow | null> {
|
||||||
|
const entry = await this.getById(id)
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (entry.fileName !== fileName) {
|
||||||
|
const existing = await WIKI.db
|
||||||
|
.select({ id: treeTable.id })
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
ne(treeTable.id, entry.id),
|
||||||
|
eq(treeTable.siteId, entry.siteId),
|
||||||
|
eq(treeTable.locale, entry.locale),
|
||||||
|
eq(treeTable.folderPath, entry.folderPath ?? ''),
|
||||||
|
eq(treeTable.fileName, fileName)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeEntryDuplicate',
|
||||||
|
'Something with this name already exists here.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = decodeTreePath(entry.folderPath ?? '')
|
||||||
|
const fullPath = folderPath ? `${folderPath}/${fileName}` : fileName
|
||||||
|
const updated = await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({
|
||||||
|
fileName,
|
||||||
|
title: title ?? entry.title,
|
||||||
|
hash: generateHash(fullPath),
|
||||||
|
updatedAt: sql`now()`
|
||||||
|
})
|
||||||
|
.where(eq(treeTable.id, entry.id))
|
||||||
|
.returning()
|
||||||
|
return updated[0] as TreeRow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a page or asset entry from the tree, keeping its folder's count straight.
|
||||||
|
*/
|
||||||
|
async deleteEntry(id: string): Promise<boolean> {
|
||||||
|
const entry = await this.getById(id)
|
||||||
|
if (!entry) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await WIKI.db.delete(treeTable).where(eq(treeTable.id, id))
|
||||||
|
await this.countTowardsFolderAt(entry.siteId, entry.folderPath ?? '', -1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a page or asset row, resolving its folder first and counting it against that folder.
|
||||||
|
*/
|
||||||
|
private async addEntry({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
parentId,
|
||||||
|
parentPath,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags,
|
||||||
|
meta,
|
||||||
|
navigationId,
|
||||||
|
onConflict
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
type: Exclude<TreeItemType, 'folder'>
|
||||||
|
parentId?: string | null
|
||||||
|
parentPath?: string | null
|
||||||
|
fileName: string
|
||||||
|
title: string
|
||||||
|
locale: string
|
||||||
|
siteId: string
|
||||||
|
tags: string[]
|
||||||
|
meta: Record<string, any>
|
||||||
|
navigationId?: string
|
||||||
|
onConflict: 'error' | 'suffix'
|
||||||
|
}): Promise<TreeRow> {
|
||||||
|
const folder =
|
||||||
|
parentId || parentPath
|
||||||
|
? await this.getFolder({
|
||||||
|
id: parentId,
|
||||||
|
path: parentPath,
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
createIfMissing: true
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const path = folder ? childPathOf(folder) : ''
|
||||||
|
|
||||||
|
const name = await this.resolveName({ siteId, locale, path, fileName, onConflict })
|
||||||
|
const fullPath = path ? `${decodeTreePath(path)}/${name}` : name
|
||||||
|
|
||||||
|
WIKI.logger.debug(`Adding ${type} ${fullPath} to tree...`)
|
||||||
|
|
||||||
|
const inserted = await WIKI.db
|
||||||
|
.insert(treeTable)
|
||||||
|
.values({
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
folderPath: path,
|
||||||
|
fileName: name,
|
||||||
|
type,
|
||||||
|
// -> A title that was only ever the file name follows it when the name had to change, so that
|
||||||
|
// two uploads of `photo.png` do not both show up called `photo.png`
|
||||||
|
title: title === fileName ? name : title,
|
||||||
|
hash: generateHash(fullPath),
|
||||||
|
locale,
|
||||||
|
siteId,
|
||||||
|
tags,
|
||||||
|
meta,
|
||||||
|
...(navigationId ? { navigationId } : {})
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
await this.countTowardsFolderAt(siteId, path, 1)
|
||||||
|
|
||||||
|
return inserted[0] as TreeRow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle on a file name that nothing in the folder is already using.
|
||||||
|
*
|
||||||
|
* Two entries with the same name in the same folder would share a path, and therefore a hash — the
|
||||||
|
* second one would shadow the first everywhere it is looked up by URL. An upload takes the next free
|
||||||
|
* `name-1.ext`, the way a file manager is expected to; anything else says so instead.
|
||||||
|
*/
|
||||||
|
private async resolveName({
|
||||||
|
siteId,
|
||||||
|
locale,
|
||||||
|
path,
|
||||||
|
fileName,
|
||||||
|
onConflict
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
locale: string
|
||||||
|
path: string
|
||||||
|
fileName: string
|
||||||
|
onConflict: 'error' | 'suffix'
|
||||||
|
}): Promise<string> {
|
||||||
|
const taken = async (name: string) =>
|
||||||
|
(
|
||||||
|
await WIKI.db
|
||||||
|
.select({ id: treeTable.id })
|
||||||
|
.from(treeTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(treeTable.siteId, siteId),
|
||||||
|
eq(treeTable.locale, locale),
|
||||||
|
eq(treeTable.folderPath, path),
|
||||||
|
eq(treeTable.fileName, name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).length > 0
|
||||||
|
|
||||||
|
if (!(await taken(fileName))) {
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
if (onConflict === 'error') {
|
||||||
|
throw new CustomError(
|
||||||
|
'treeEntryDuplicate',
|
||||||
|
'Something with this name already exists here.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = fileName.lastIndexOf('.')
|
||||||
|
const stem = dot > 0 ? fileName.slice(0, dot) : fileName
|
||||||
|
const ext = dot > 0 ? fileName.slice(dot) : ''
|
||||||
|
for (let i = 1; i <= MAX_NAME_ATTEMPTS; i++) {
|
||||||
|
const candidate = `${stem}-${i}${ext}`
|
||||||
|
if (!(await taken(candidate))) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new CustomError(
|
||||||
|
'treeEntryDuplicate',
|
||||||
|
'Too many files in this folder are already named this.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the children count of the folder sitting at an ltree path.
|
||||||
|
*
|
||||||
|
* The count lives on the folder rather than being counted on read, so it has to be kept straight by
|
||||||
|
* whoever adds or removes something. The arithmetic is done in postgres rather than read-then-write
|
||||||
|
* so that two concurrent uploads into the same folder cannot lose one another's increment.
|
||||||
|
*
|
||||||
|
* An empty path is the site root, which is not a folder and has nothing to count.
|
||||||
|
*/
|
||||||
|
private async countTowardsFolderAt(siteId: string, path: string, delta: number): Promise<void> {
|
||||||
|
if (!path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const location = splitPath(path)
|
||||||
|
await WIKI.db
|
||||||
|
.update(treeTable)
|
||||||
|
.set({
|
||||||
|
meta: sql`jsonb_set(${treeTable.meta}, '{children}', to_jsonb(GREATEST(0, COALESCE((${treeTable.meta}->>'children')::int, 0) + ${delta})))`
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(treeTable.siteId, siteId),
|
||||||
|
eq(treeTable.folderPath, location.folderPath),
|
||||||
|
eq(treeTable.fileName, location.fileName),
|
||||||
|
eq(treeTable.type, 'folder')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tree = new Tree()
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Headless rendering entry point.
|
||||||
|
*
|
||||||
|
* The server cannot render markdown — the pipeline lives here, in the browser, and duplicating it
|
||||||
|
* would mean two renderers that drift apart and an editor preview that stops matching the saved page.
|
||||||
|
* So when the server needs to re-render a page from its source, it drives a real browser instead:
|
||||||
|
* Puppeteer loads the `/_render` shell, which loads this bundle, and calls `__wikiRender`.
|
||||||
|
*
|
||||||
|
* Built to a fixed filename (`_assets/renderer.js`, see `vite.config.js`) because the backend has to
|
||||||
|
* reference it from a static page and cannot resolve a hashed one.
|
||||||
|
*/
|
||||||
|
import { MarkdownRenderer } from './markdown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render markdown the way the editor does.
|
||||||
|
*
|
||||||
|
* @param {string} content Markdown source
|
||||||
|
* @param {object} config The site's markdown editor config, so the result matches what an author
|
||||||
|
* would have produced in the editor
|
||||||
|
* @returns {string} Rendered HTML, before the server's own post-processing
|
||||||
|
*/
|
||||||
|
window.__wikiRender = function (content, config = {}) {
|
||||||
|
const renderer = new MarkdownRenderer(config)
|
||||||
|
return renderer.render(content ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Polled by the caller: a module script is deferred, so the page can be "loaded" before this ran
|
||||||
|
window.__wikiRenderReady = true
|
||||||
Loading…
Reference in new issue