refactor: wire the editing / browsing views

scarlett
NGPixel 2 months ago
parent 40a0abc3bb
commit 4507d9f7ad
No known key found for this signature in database

@ -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

@ -6,6 +6,7 @@ import type { FastifyInstance } from 'fastify'
async function routes(app: FastifyInstance) {
// Register schemas
await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app))
await import('./schemas/asset.ts').then((m) => m.registerSchemas(app))
await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app))
await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
@ -14,14 +15,17 @@ async function routes(app: FastifyInstance) {
await import('./schemas/hook.ts').then((m) => m.registerSchemas(app))
await import('./schemas/icon.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/page.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/security.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/storage.ts').then((m) => m.registerSchemas(app))
await import('./schemas/tree.ts').then((m) => m.registerSchemas(app))
await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes
app.register(import('./apiKeys.ts'), { prefix: '/api-keys' })
app.register(import('./assets.ts'))
app.register(import('./authentication.ts'))
app.register(import('./blocks.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
@ -29,11 +33,14 @@ async function routes(app: FastifyInstance) {
app.register(import('./icons.ts'), { prefix: '/icons' })
app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./navigation.ts'))
app.register(import('./pages.ts'))
app.register(import('./scheduler.ts'), { prefix: '/scheduler' })
app.register(import('./sites.ts'), { prefix: '/sites' })
app.register(import('./storage.ts'))
app.register(import('./system.ts'), { prefix: '/system' })
app.register(import('./tags.ts'))
app.register(import('./tree.ts'))
app.register(import('./users.ts'), { prefix: '/users' })
}

@ -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

@ -1,9 +1,67 @@
import type { FastifyInstance } from 'fastify'
import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
function splitList(value?: string): string[] {
return (
value
?.split(',')
.map((v) => v.trim())
.filter(Boolean) ?? []
)
}
const siteIdParam = {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
}
const pageIdParam = {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
pageId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'pageId']
}
/**
* Who is saving, and what they may embed.
*
* A page records an author, so it takes a logged in user rather than an API key and the author's
* permissions are what the render is sanitized against.
*/
function actorFrom(req: FastifyRequest): PageActor | null {
if (!req.session?.authenticated || !req.session.user?.id) {
return null
}
return {
id: req.session.user.id,
permissions: req.session.permissions ?? []
}
}
/**
* Pages API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST PAGES
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/pages',
{
@ -12,31 +70,176 @@ async function routes(app: FastifyInstance) {
},
schema: {
summary: 'List all pages',
description:
'Not implemented yet — always answers with an empty list. Browse the tree instead, which is what the file manager and the navigation use.',
tags: ['Pages'],
params: {
params: siteIdParam,
response: {
200: {
description: 'List of pages',
type: 'array',
items: { $ref: 'Page#' }
}
}
}
},
async () => {
return []
}
)
/**
* SEARCH PAGES
*/
app.get<{
Params: { siteId: string }
Querystring: {
query?: string
path?: string
locales?: string
tags?: string
editor?: string
publishState?: string
orderBy?: SearchOrderBy
orderByDirection?: 'asc' | 'desc'
offset?: number
limit?: number
}
}>(
'/sites/:siteId/pages/search',
{
schema: {
summary: 'Search pages',
description:
'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages with no password on them. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\n`highlight` is an excerpt with the matched terms wrapped in `<b>`, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.',
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
siteId: {
query: {
type: 'string',
format: 'uuid'
maxLength: 2048,
description: 'Free text. Understands quoted phrases, `or` and `-exclusions`.'
},
path: {
type: 'string',
maxLength: 2048,
description: 'Only pages whose path starts with this.'
},
locales: {
type: 'string',
maxLength: 255,
description: 'Comma-separated locale codes. Every locale when absent.'
},
tags: {
type: 'string',
maxLength: 2048,
description: 'Comma-separated tags a page must carry all of.'
},
editor: {
type: 'string',
maxLength: 255
},
publishState: {
type: 'string',
enum: ['draft', 'published', 'scheduled']
},
orderBy: {
type: 'string',
enum: SEARCH_ORDER_BY,
default: 'relevancy'
},
orderByDirection: {
type: 'string',
enum: ['asc', 'desc'],
default: 'desc'
},
offset: {
type: 'integer',
minimum: 0,
default: 0
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 25
}
}
},
response: {
200: {
description: 'Matching pages, plus how many there are in total',
type: 'object',
properties: {
results: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
path: { type: 'string' },
locale: { type: 'string' },
title: { type: 'string' },
description: { type: ['string', 'null'] },
icon: { type: ['string', 'null'] },
tags: { type: 'array', items: { type: 'string' } },
updatedAt: { type: 'string', format: 'date-time' },
relevancy: { type: 'number' },
highlight: {
type: ['string', 'null'],
description: 'Excerpt with matched terms in `<b>`, everything else escaped.'
}
}
}
},
totalHits: {
type: 'integer',
description: 'How many pages match, ignoring `limit` and `offset`.'
}
}
}
}
}
},
async () => {
return []
async (req) => {
const actor = actorFrom(req)
const permissions = actor?.permissions ?? []
return WIKI.models.search.searchPages({
siteId: req.params.siteId,
query: req.query.query,
path: req.query.path,
locales: splitList(req.query.locales),
tags: splitList(req.query.tags),
editor: req.query.editor,
publishState: req.query.publishState,
orderBy: req.query.orderBy,
orderByDirection: req.query.orderByDirection,
offset: req.query.offset,
limit: req.query.limit,
publicOnly: !actor,
// -> An unpublished page is only of interest to someone who could have written it
includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) =>
permissions.includes(p)
)
})
}
)
/**
* GET PAGE
*/
app.get<{
Params: { siteId: string; pageIdOrHash: string }
Querystring: { withContent?: boolean }
Querystring: { withContent?: boolean; locale?: string }
}>(
'/sites/:siteId/pages/:pageIdOrHash',
{
schema: {
summary: 'Get a single page',
description:
"Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages with no password on them, and never their source. Per-page access rules are not implemented yet.",
tags: ['Pages'],
params: {
type: 'object',
@ -49,29 +252,299 @@ async function routes(app: FastifyInstance) {
type: 'string',
oneOf: [{ format: 'uuid' }, { pattern: '^[a-f0-9]+$' }]
}
}
},
required: ['siteId', 'pageIdOrHash']
},
querystring: {
type: 'object',
properties: {
withContent: {
type: 'boolean',
default: false
default: false,
description: 'Include the source, which only an editor needs.'
},
locale: {
type: 'string',
maxLength: 10
}
}
},
response: {
200: { $ref: 'Page#' }
}
}
},
async () => {
return []
async (req, reply) => {
const isId = uuidValidate(req.params.pageIdOrHash)
const actor = actorFrom(req)
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }),
locale: req.query.locale,
// -> The source is what an editor loads, and editing is not something an anonymous reader does
withContent: Boolean(req.query.withContent) && Boolean(actor),
publicOnly: !actor
})
if (!page) {
return reply.notFound('This page does not exist.')
}
return page
}
)
app.post<{ Params: { siteId: string }; Body: { path: string } }>(
'/sites/:siteId/pages/userPermissions',
/**
* CREATE PAGE
*/
app.post<{ Params: { siteId: string }; Body: PageInput }>(
'/sites/:siteId/pages',
{
config: {
permissions: ['write:pages', 'manage:pages']
},
schema: {
summary: 'Get page user permissions',
summary: 'Create a page',
description:
'The content is the source and `render` is the HTML the editor produced from it. The render is sanitized against what the author may embed, stripped of editor scaffolding, given heading anchors, and reduced to a table of contents and search text — so read the response rather than assuming what was sent is what was stored.',
tags: ['Pages'],
params: siteIdParam,
body: {
allOf: [{ $ref: 'PageInput#' }, { required: ['path', 'title', 'editor', 'content'] }]
},
response: {
200: {
description: 'Page created successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Saving a page requires a logged in user.')
}
const page = await WIKI.models.pages.createPage(req.params.siteId, req.body, actor)
return {
ok: true,
message: 'Page created successfully.',
page
}
}
)
/**
* UPDATE PAGE
*/
app.patch<{ Params: { siteId: string; pageId: string }; Body: Partial<PageInput> }>(
'/sites/:siteId/pages/:pageId',
{
config: {
permissions: ['write:pages', 'manage:pages']
},
schema: {
summary: 'Update a page',
description:
'Accepts any subset of the fields. Sending `render` replaces the stored HTML, its table of contents and its search text; sending `content` without it leaves the previous render in place, which is what a source-only edit means.',
tags: ['Pages'],
params: pageIdParam,
body: { $ref: 'PageInput#' },
response: {
200: {
description: 'Page updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Saving a page requires a logged in user.')
}
const page = await WIKI.models.pages.updatePage(
req.params.siteId,
req.params.pageId,
req.body,
actor
)
if (!page) {
return reply.notFound('This page does not exist.')
}
return {
ok: true,
message: 'Page updated successfully.',
page
}
}
)
/**
* MOVE / RENAME PAGE
*/
app.put<{
Params: { siteId: string; pageId: string }
Body: { path: string; title?: string }
}>(
'/sites/:siteId/pages/:pageId/path',
{
config: {
permissions: ['manage:pages']
},
schema: {
summary: 'Move a page to another path',
description:
'Also renames it when a title is given. The tree entry moves with it, and any folder the new path needs is created.',
tags: ['Pages'],
params: pageIdParam,
body: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
maxLength: 255,
pattern: '^/?[a-zA-Z0-9-_/]*$'
},
title: {
type: 'string',
minLength: 1,
maxLength: 255
}
}
},
response: {
200: {
description: 'Page moved successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Moving a page requires a logged in user.')
}
const page = await WIKI.models.pages.movePage(
req.params.siteId,
req.params.pageId,
req.body,
actor
)
if (!page) {
return reply.notFound('This page does not exist.')
}
return {
ok: true,
message: 'Page moved successfully.',
page
}
}
)
/**
* RE-RENDER PAGE
*/
app.post<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/render',
{
config: {
permissions: ['write:pages', 'manage:pages']
},
schema: {
summary: 'Render a page again from its source',
description:
'For when a stored render has gone stale and nobody has the page open to re-save it. The markdown pipeline lives in the frontend, so the server drives it in a headless browser and the result matches what the editor would produce — which means this needs the Puppeteer extension, and answers 503 without it.',
tags: ['Pages'],
params: pageIdParam,
response: {
200: {
description: 'Page rendered successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Rendering a page requires a logged in user.')
}
const page = await WIKI.models.pages.rerenderPage(req.params.siteId, req.params.pageId, actor)
if (!page) {
return reply.notFound('This page does not exist.')
}
return {
ok: true,
message: 'Page rendered successfully.',
page
}
}
)
/**
* DELETE PAGE
*/
app.delete<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId',
{
config: {
permissions: ['delete:pages', 'manage:pages']
},
schema: {
summary: 'Delete a page',
tags: ['Pages'],
params: pageIdParam,
response: {
204: {
description: 'Page deleted successfully'
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Deleting a page requires a logged in user.')
}
if (!(await WIKI.models.pages.deletePage(req.params.siteId, req.params.pageId, actor))) {
return reply.notFound('This page does not exist.')
}
return reply.code(204).send()
}
)
/**
* RESOLVE ALIAS
*/
app.get<{ Params: { siteId: string; alias: string } }>(
'/sites/:siteId/pages/alias/:alias',
{
config: {
permissions: ['read:pages', 'manage:pages']
},
schema: {
summary: 'Resolve a page alias to its path',
tags: ['Pages'],
params: {
type: 'object',
@ -79,9 +552,48 @@ async function routes(app: FastifyInstance) {
siteId: {
type: 'string',
format: 'uuid'
},
alias: {
type: 'string',
maxLength: 255,
pattern: '^[a-zA-Z0-9-_]+$'
}
}
},
required: ['siteId', 'alias']
},
response: {
200: {
description: 'The page the alias points at',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
path: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const target = await WIKI.models.pages.getPathFromAlias(req.params.siteId, req.params.alias)
if (!target) {
return reply.notFound('No page uses this alias.')
}
return target
}
)
/**
* PAGE USER PERMISSIONS
*/
app.post<{ Params: { siteId: string }; Body: { path: string } }>(
'/sites/:siteId/pages/userPermissions',
{
schema: {
summary: 'Get page user permissions',
description:
"The current user's page permissions, which are not yet scoped per path — every page in the site answers the same.",
tags: ['Pages'],
params: siteIdParam,
body: {
type: 'object',
required: ['path'],
@ -97,11 +609,24 @@ async function routes(app: FastifyInstance) {
path: 'foo/bar'
}
]
},
response: {
200: {
description: 'Permissions the current user holds for this page',
type: 'array',
items: { type: 'string' }
}
}
}
},
async () => {
return []
async (req) => {
const actor = actorFrom(req)
if (!actor) {
return []
}
// FIXME: per-path permission rules are not implemented — a group's page permissions apply to
// the whole site, so this returns what the user holds anywhere rather than here.
return actor.permissions.filter((p) => p.endsWith(':pages'))
}
)
}

@ -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

@ -19,7 +19,9 @@ import {
// == CUSTOM TYPES =====================
const ltree = customType({
// -> Typed as a string: an ltree path comes back from the driver as its dotted text form, and every
// caller treats it as one
const ltree = customType<{ data: string }>({
dataType() {
return 'ltree'
}

@ -80,6 +80,34 @@ export function generateHash(str: string): string {
return crypto.createHash('sha1').update(str).digest('hex')
}
/**
* Hash a page path the way the frontend does.
*
* A page is addressed by the hash of its path rather than the path itself, so that a URL with slashes
* in it stays a single path segment. The frontend computes this before asking for a page, so the two
* implementations have to agree exactly this is cyrb53, mirroring `fastHash` in
* `frontend/src/stores/page.js`. Not a security boundary: it is a lookup key, and it is checked
* against the site it was requested for.
*
* @param str Page path, without a leading slash
* @returns 53-bit hash as a hex string
*/
export function generatePathHash(str: string, seed = 0): string {
let h1 = 0xdeadbeef ^ seed
let h2 = 0x41c6ce57 ^ seed
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16)
}
/**
* Get default value of type
*

@ -76,3 +76,44 @@ export async function resizeImageToSquareJpeg(data: Buffer, size: number): Promi
return null
}
}
/**
* Shrink an image to a WebP thumbnail, using the Sharp extension.
*
* Unlike an avatar, a thumbnail has no fallback: a file manager that cannot make one simply shows the
* file type icon instead, so null here is an ordinary outcome rather than a degraded one.
*
* @returns The thumbnail, or null if Sharp is not usable on this system or these bytes are not an
* image it can read
*/
export async function makeImageThumbnail(
data: Buffer,
width: number,
height: number
): Promise<Buffer | null> {
const definition = WIKI.models.extensions.getDefinition('sharp')
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
return null
}
const specifier = 'sharp'
// -> Loading Sharp and running it are kept apart here, unlike above: whatever a user uploaded may
// simply not be an image Sharp can read, and that must not be recorded as Sharp itself being
// broken for the rest of the process
let sharp: any
try {
;({ default: sharp } = await import(specifier))
} catch (err: any) {
WIKI.models.extensions.noteLoadFailure(specifier)
WIKI.logger.warn(`Could not load Sharp to generate a thumbnail: ${err.message}`)
return null
}
try {
return await sharp(data)
.resize(width, height, { fit: 'cover', position: 'centre', withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer()
} catch (err: any) {
WIKI.logger.debug(`Could not generate a thumbnail for an upload: ${err.message}`)
return null
}
}

@ -550,6 +550,8 @@ async function initHTTPServer() {
app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' })
app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
app.register(import('./controllers/render.ts'), { prefix: '/_render' })
app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' })
app.register(import('./controllers/user.ts'), { prefix: '/_user' })
// ----------------------------------------

@ -1714,6 +1714,7 @@
"editor.props.relations": "Relations",
"editor.props.requirePassword": "Require Password",
"editor.props.scripts": "Scripts",
"editor.props.selectIcon": "Select Icon...",
"editor.props.shortDescription": "Short Description",
"editor.props.showInTree": "Show in Site Navigation",
"editor.props.showSidebar": "Show Sidebar",
@ -1724,7 +1725,9 @@
"editor.props.styles": "CSS Styles",
"editor.props.stylesHint": "CSS Rules to add to the page",
"editor.props.tags": "Tags",
"editor.props.tagsFailed": "Failed to load existing tags. You can still add new ones.",
"editor.props.tagsHint": "Use tags to categorize your pages and make them easier to find.",
"editor.props.tagsPlaceholder": "Select or create tags...",
"editor.props.title": "Title",
"editor.props.tocMinMaxDepth": "Min/Max Depth",
"editor.props.visibility": "Visibility",
@ -1874,6 +1877,7 @@
"navEdit.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items",
"navEdit.emptyMenuText": "Click the Add button to add your first menu item.",
"navEdit.groupsFailed": "Failed to load the list of groups. Per-group visibility cannot be changed.",
"navEdit.header": "Header",
"navEdit.icon": "Icon",
"navEdit.iconHint": "Icon to display to the left of the menu item.",
@ -1903,9 +1907,17 @@
"pageDeleteDialog.title": "Confirm Page Deletion",
"pageDuplicateDialog.title": "Duplicate and Save As...",
"pageRenameDialog.title": "Rename / Move to...",
"pageSource.notFound": "This page does not exist.",
"pageSource.title": "Page Source",
"pageSource.unavailable": "You do not have permission to view the source of this page.",
"pageSaveDialog.displayModePath": "Browse Using Paths",
"pageSaveDialog.displayModeTitle": "Browse Using Titles",
"pageSaveDialog.loadFailed": "Failed to load folder tree.",
"pageSaveDialog.pathInvalid": "Invalid Characters in Page Path Name. Lowercase alphanumerical and hyphen characters only.",
"pageSaveDialog.pathName": "Path Name",
"pageSaveDialog.pageTitle": "Page Title",
"pageSaveDialog.title": "Save As...",
"pageSaveDialog.titleMissing": "Missing Page Title",
"profile.accessibility": "Accessibility",
"profile.activity": "Activity",
"profile.appearance": "Site Appearance",
@ -2002,6 +2014,7 @@
"renderPageDialog.success": "Page rerendered successfully.",
"search.editorAny": "Any editor",
"search.emptyQuery": "Enter a query in the search field above and press Enter.",
"search.failed": "Failed to perform search query.",
"search.filterEditor": "Editor",
"search.filterLocale": "Locale(s)",
"search.filterLocaleDisplay": "Any locale | {n} locale only | {count} locales selected",

@ -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()

@ -6,8 +6,8 @@ import { desc, eq, sql } from 'drizzle-orm'
/**
* The events a webhook can subscribe to, as offered by the admin area.
*
* Only the user events have emit points today pages, assets and comments are not implemented yet,
* so subscribing to them stores a subscription that nothing triggers.
* Not all of them have emit points today pages and comments are not implemented yet, so subscribing
* to those stores a subscription that nothing triggers.
*/
export const HOOK_EVENTS = [
'page:create',
@ -31,10 +31,17 @@ export type HookEvent = (typeof HOOK_EVENTS)[number]
/**
* The events something in the server actually emits today.
*
* Kept as an explicit list rather than inferred from the prefix, since the page, asset and comment
* events have no emit point yet. Add an event here when you add its `emit()` call.
* Kept as an explicit list rather than inferred from the prefix, since the page and comment events
* have no emit point yet. Add an event here when you add its `emit()` call.
*/
export const EMITTED_EVENTS: HookEvent[] = ['user:join', 'user:login', 'user:logout']
export const EMITTED_EVENTS: HookEvent[] = [
'asset:upload',
'asset:rename',
'asset:delete',
'user:join',
'user:login',
'user:logout'
]
/** A webhook as exposed by the API. */
export interface Hook {

@ -1,4 +1,5 @@
import { apiKeys } from './apiKeys.ts'
import { assets } from './assets.ts'
import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts'
import { extensions } from './extensions.ts'
@ -8,16 +9,22 @@ import { hooks } from './hooks.ts'
import { icons } from './icons.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { navigation } from './navigation.ts'
import { pages } from './pages.ts'
import { rendering } from './rendering.ts'
import { search } from './search.ts'
import { security } from './security.ts'
import { sessions } from './sessions.ts'
import { settings } from './settings.ts'
import { sites } from './sites.ts'
import { storage } from './storage.ts'
import { tags } from './tags.ts'
import { tree } from './tree.ts'
import { users } from './users.ts'
export default {
apiKeys,
assets,
authentication,
blocks,
extensions,
@ -27,11 +34,16 @@ export default {
icons,
jobs,
locales,
navigation,
pages,
rendering,
search,
security,
sessions,
settings,
sites,
storage,
tags,
tree,
users
}

@ -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()

@ -53,6 +53,68 @@ export interface RebuildResult {
locales: { locale: string; dictionary: string; pages: number }[]
}
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'updatedAt'] as const
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
export interface SearchResult {
id: string
path: string
locale: string
title: string
description: string | null
icon: string | null
tags: string[]
updatedAt: string
relevancy: number
highlight: string | null
}
export interface SearchPagesResult {
results: SearchResult[]
totalHits: number
}
export interface SearchPagesParams {
siteId: string
query?: string
path?: string
locales?: string[]
tags?: string[]
editor?: string
publishState?: string
orderBy?: SearchOrderBy
orderByDirection?: 'asc' | 'desc'
offset?: number
limit?: number
/** Restrict to what a reader with no session may see: published, and not password protected. */
publicOnly?: boolean
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
includeDrafts?: boolean
}
/**
* Markers `ts_headline` wraps a matched term in.
*
* Control characters, because the excerpt is page text that may itself contain anything: it is HTML
* escaped before these are turned into tags, so a page whose text reads `<script>` cannot come back as
* markup. Anything that could occur in real text would defeat that.
*/
const HL_START = '\u0002'
const HL_STOP = '\u0003'
/** Escape the LIKE wildcards, so that a path filter is a prefix rather than a pattern. */
function escapeLikePrefix(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/**
* Search model
*
@ -105,6 +167,154 @@ class Search {
return FALLBACK_DICTIONARY
}
/**
* A SQL expression giving the text search dictionary to use for each row.
*
* The vector on a page was built with its own locale's dictionary, so the query has to be parsed
* with the same one an English query stemmed as French matches nothing. Postgres accepts a
* `regconfig` expression, so the mapping travels with the row rather than being fixed per query.
*
* @param locales Locales the search covers, which is what the CASE needs arms for
* @param available Dictionary names postgres knows
*/
private dictionaryExpression(locales: string[], available: string[]) {
const arms = locales.map((locale) => {
const dictionary = this.dictionaryForLocale(locale, available)
// -> Both sides are checked values: the locale is compared as a parameter, and the dictionary
// name is one postgres itself reported
return sql`WHEN ${locale} THEN ${sql.raw(`'${dictionary}'`)}`
})
if (arms.length < 1) {
return sql`${sql.raw(`'${FALLBACK_DICTIONARY}'`)}::regconfig`
}
return sql`(CASE p.locale::text ${sql.join(arms, sql` `)} ELSE ${sql.raw(`'${FALLBACK_DICTIONARY}'`)} END)::regconfig`
}
/**
* Full-text search over the pages of a site.
*
* The text query is optional: with only tags or filters this is a browse rather than a search, which
* is what a query of nothing but `#tags` amounts to. Ranking needs matched terms, so ordering by
* relevancy without a query falls back to the most recently updated.
*
* `isSearchable` is honoured for everyone a page excluded from search was excluded on purpose.
*/
async searchPages({
siteId,
query = '',
path = '',
locales = [],
tags = [],
editor = '',
publishState = '',
orderBy = 'relevancy',
orderByDirection = 'desc',
offset = 0,
limit = 25,
publicOnly = false,
includeDrafts = false
}: SearchPagesParams): Promise<SearchPagesResult> {
const terms = query.trim()
const hasQuery = terms.length > 0
// -> Only the locales in play need an arm in the dictionary CASE
const siteLocales: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? ['en']
const searchedLocales = locales.length > 0 ? locales : siteLocales
const dict = this.dictionaryExpression(
searchedLocales,
hasQuery ? await this.getAvailableDictionaries() : []
)
const tsQuery = sql`websearch_to_tsquery(${dict}, ${terms})`
const conditions = [sql`p."siteId" = ${siteId}`, sql`p."isSearchable" = true`]
if (hasQuery) {
conditions.push(sql`p.ts @@ ${tsQuery}`)
}
if (publicOnly) {
// -> Matches what a page view shows an anonymous reader, so that search cannot surface a page
// that could not then be opened
conditions.push(sql`p."publishState" = 'published'`)
conditions.push(sql`p.password IS NULL`)
} else if (!includeDrafts) {
conditions.push(sql`p."publishState" <> 'draft'`)
}
if (publishState) {
conditions.push(sql`p."publishState" = ${publishState}`)
}
if (path) {
conditions.push(sql`p.path LIKE ${`${escapeLikePrefix(path)}%`}`)
}
if (locales.length > 0) {
// -> `sql.param`, because a bare array is expanded into a list of placeholders rather than
// bound as one array value
conditions.push(sql`p.locale::text = ANY(${sql.param(locales)}::text[])`)
}
if (tags.length > 0) {
conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
}
if (editor) {
conditions.push(sql`p.editor = ${editor}`)
}
const direction = orderByDirection === 'asc' ? sql`ASC` : sql`DESC`
// -> Every page ranks 0 without a query, which would leave the order down to the planner
const effectiveOrderBy = orderBy === 'relevancy' && !hasQuery ? 'updatedAt' : orderBy
const ordering = {
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
title: sql`p.title ${direction}`,
updatedAt: sql`p."updatedAt" ${direction}`
}[effectiveOrderBy]
const { termHighlighting } = this.getConfig()
const highlight =
hasQuery && termHighlighting
? sql`ts_headline(${dict}, coalesce(p."searchContent", ''), ${tsQuery},
${`StartSel=${HL_START},StopSel=${HL_STOP},MaxWords=25,MinWords=10,MaxFragments=1`})`
: sql`NULL`
const rows = await WIKI.db.execute(sql`
SELECT
p.id,
p.path,
p.locale::text AS locale,
p.title,
p.description,
p.icon,
p.tags,
to_char(p."updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt",
${hasQuery ? sql`ts_rank(p.ts, ${tsQuery})` : sql`0`} AS relevancy,
${highlight} AS highlight,
COUNT(*) OVER() AS "totalHits"
FROM pages p
WHERE ${sql.join(conditions, sql` AND `)}
ORDER BY ${ordering}
LIMIT ${limit} OFFSET ${offset}
`)
const result = ((rows.rows ?? rows) as any[]).map((row) => ({
id: row.id as string,
path: row.path as string,
locale: row.locale as string,
title: row.title as string,
description: row.description ?? null,
icon: row.icon ?? null,
tags: (row.tags ?? []) as string[],
updatedAt: row.updatedAt as string,
relevancy: Number(row.relevancy ?? 0),
// -> Escaped first, so the only markup that survives is the emphasis postgres marked
highlight: row.highlight
? escapeHtml(row.highlight as string)
.replaceAll(HL_START, '<b>')
.replaceAll(HL_STOP, '</b>')
: null
}))
return {
results: result,
totalHits: Number((rows.rows ?? rows)[0]?.totalHits ?? 0)
}
}
/**
* Recompute the search vector of every page.
*
@ -147,6 +357,33 @@ class Search {
WIKI.logger.info(`Search index rebuild completed: ${result.pages} page(s) [ OK ]`)
return result
}
/**
* Recompute one page's search vector, after it was created or edited.
*
* Same weighting as a full rebuild title above description above body so that a page saved
* today ranks against pages last indexed by a rebuild rather than alongside them.
*
* Never throws: a page that saved correctly must not report failure because its index entry could
* not be written, and the next rebuild puts it right.
*/
async indexPage(id: string, locale: string): Promise<void> {
try {
const dictionary = this.dictionaryForLocale(locale, await this.getAvailableDictionaries())
// -> The dictionary name is an identifier in `to_tsvector`, and it is only ever one of the
// names postgres itself reported, so it cannot carry anything unexpected
const dict = sql.raw(`'${dictionary}'`)
await WIKI.db.execute(sql`
UPDATE pages SET ts =
setweight(to_tsvector(${dict}, coalesce(title, '')), 'A') ||
setweight(to_tsvector(${dict}, coalesce(description, '')), 'B') ||
setweight(to_tsvector(${dict}, coalesce("searchContent", '')), 'C')
WHERE id = ${id}
`)
} catch (err: any) {
WIKI.logger.warn(`Failed to update the search index for page ${id}: ${err.message}`)
}
}
}
export const search = new Search()

@ -179,13 +179,10 @@ class Sites {
const newSite = result[0]
// WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
// await WIKI.db.navigation.query().insert({
// id: newSite.id,
// siteId: newSite.id,
// items: []
// })
// -> The menu every page of the site inherits, keyed by the site id. Empty to begin with, but it
// has to exist before a page can point at it
WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
await WIKI.models.navigation.ensureSiteNav(newSite.id)
// -> Site lookups by id / hostname are served from cache, which must know about the new site
await WIKI.models.sites.reloadCache()

@ -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()

@ -982,6 +982,8 @@ class Users {
cvd: user.prefs?.cvd
}
req.session.permissions = uniq(flatten(user.groups?.map((g: any) => g.permissions)))
// -> Group ids as well as their permissions, since navigation items are limited per group
req.session.groups = (user.groups ?? []).map((g: any) => g.id)
}
async generateToken({

@ -26,6 +26,7 @@
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",
"cheerio": "1.2.0",
"cron-parser": "5.5.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"emittery": "2.0.0",
@ -42,8 +43,8 @@
"pg": "8.21.0",
"poolifier": "5.3.2",
"pug": "3.0.4",
"sanitize-html": "2.17.6",
"semver": "7.8.4",
"sharp": "*",
"uuid": "14.0.0"
},
"devDependencies": {
@ -53,6 +54,7 @@
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"nodemon": "3.1.14",
@ -2676,6 +2678,16 @@
"@types/node": "*"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@ -3263,6 +3275,12 @@
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@ -3391,6 +3409,48 @@
"is-regex": "^1.0.3"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -3511,6 +3571,40 @@
"node": ">=18"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3528,6 +3622,15 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
@ -3605,6 +3708,61 @@
"integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/drizzle-kit": {
"version": "1.0.0-beta.15-859cf75",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-beta.15-859cf75.tgz",
@ -3868,6 +4026,31 @@
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -3877,6 +4060,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3965,6 +4160,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -4338,6 +4545,37 @@
"node": ">=18.0.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -4573,6 +4811,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@ -4769,6 +5016,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -5137,6 +5393,18 @@
"npm": ">=10.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5296,6 +5564,61 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT"
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -5433,6 +5756,12 @@
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/pino": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.2.0.tgz",
@ -5490,6 +5819,52 @@
"pnpm": ">=9.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -5878,6 +6253,125 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.6",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz",
"integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/sanitize-html/node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@ -6003,6 +6497,15 @@
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -6273,6 +6776,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@ -6325,6 +6837,40 @@
"node": ">=0.10.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/with": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz",

@ -52,6 +52,7 @@
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"chalk": "5.6.2",
"cheerio": "1.2.0",
"cron-parser": "5.5.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"emittery": "2.0.0",
@ -68,6 +69,7 @@
"pg": "8.21.0",
"poolifier": "5.3.2",
"pug": "3.0.4",
"sanitize-html": "2.17.6",
"semver": "7.8.4",
"uuid": "14.0.0"
},
@ -81,6 +83,7 @@
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.20.0",
"@types/pug": "2.0.10",
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.7.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"nodemon": "3.1.14",

@ -34,6 +34,8 @@ declare module 'fastify' {
}
/** Flattened, de-duplicated permissions of every group the user belongs to. */
permissions?: string[]
/** Ids of the groups the user belongs to, which is what per-group visibility is checked against. */
groups?: string[]
}
interface FastifyContextConfig {

@ -35,6 +35,8 @@ import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
// PROPS
const props = defineProps({
@ -59,6 +61,10 @@ defineEmits([
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -74,34 +80,18 @@ const state = reactive({
async function confirm () {
state.isLoading = true
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deleteAsset ($id: UUID!) {
deleteAsset(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.assetId
}
await API_CLIENT.delete(`sites/${siteStore.id}/assets/${props.assetId}`)
$q.notify({
type: 'positive',
message: t('fileman.assetDeleteSuccess')
})
if (resp?.data?.deleteAsset?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.assetDeleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deleteAsset?.operation?.message || 'An unexpected error occured.')
}
onDialogOK()
} catch (err) {
// -> ky throws above 400 an asset deleted from another tab answers 404
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.isLoading = false

@ -45,7 +45,9 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { onMounted, reactive, ref } from 'vue'
import { onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
// PROPS
@ -67,6 +69,10 @@ defineEmits([
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -86,41 +92,26 @@ async function rename () {
if (state.path?.length < 2 || !state.path?.includes('.')) {
throw new Error(t('fileman.renameAssetInvalid'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation renameAsset (
$id: UUID!
$fileName: String!
) {
renameAsset (
id: $id
fileName: $fileName
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.assetId,
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${props.assetId}`, {
json: {
fileName: state.path
}
})
if (resp?.data?.renameAsset?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.renameAssetSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.renameAsset?.operation?.message || 'An unexpected error occured.')
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('fileman.renameAssetSuccess')
})
onDialogOK()
} catch (err) {
// -> ky throws above 400 a name already taken in this folder answers 409
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.loading--
@ -131,32 +122,16 @@ async function rename () {
onMounted(async () => {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query fetchAssetForRename (
$id: UUID!
) {
assetById (
id: $id
) {
id
fileName
}
}
`,
fetchPolicy: 'network-only',
variables: {
id: props.assetId
}
})
if (resp?.data?.assetById?.id !== props.assetId) {
const asset = await API_CLIENT.get(`sites/${siteStore.id}/assets/${props.assetId}`).json()
if (asset?.id !== props.assetId) {
throw new Error('Failed to fetch asset data.')
}
state.path = resp.data.assetById.fileName
state.path = asset.fileName
} catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
onDialogCancel()
}

@ -269,7 +269,6 @@ import { reactive, ref, shallowRef, nextTick, onMounted, watch, onBeforeUnmount
import { useMeta, useQuasar, setCssVar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { find, get, last, times, startsWith, debounce } from 'lodash-es'
import { DateTime } from 'luxon'
import * as monaco from 'monaco-editor'
import { Position, Range } from 'monaco-editor'
@ -646,7 +645,7 @@ onMounted(async () => {
// -> Handle content change
editor.onDidChangeModelContent(debounce(ev => {
editorStore.$patch({
lastChangeTimestamp: DateTime.utc()
lastChangeTimestamp: Temporal.Now.instant()
})
pageStore.$patch({
content: editor.getValue()

@ -70,6 +70,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
.q-pa-md
template(v-if='currentFileDetails')
q-img.rounded-borders.q-mb-md(
v-if='currentFileDetails.thumbnail'
:src='currentFileDetails.thumbnail'
width='100%'
fit='cover'
@ -284,7 +285,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
q-item-section(side)
q-icon(name='las la-clipboard', color='primary')
q-item-section {{ t(`common.actions.copyURL`) }}
q-item(clickable, v-if='item.type !== `folder`', @click='downloadItem(item)')
q-item(clickable, v-if='item.type === `asset`', @click='downloadItem(item)')
q-item-section(side)
q-icon(name='las la-download', color='primary')
q-item-section {{ t(`common.actions.download`) }}
@ -322,9 +323,7 @@ import { useI18n } from 'vue-i18n'
import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, toRaw, watch } from 'vue'
import { filesize } from 'filesize'
import { useQuasar } from 'quasar'
import { DateTime } from 'luxon'
import { cloneDeep, dropRight, find, findKey, initial, last, nth } from 'lodash-es'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import Fuse from 'fuse.js/basic'
@ -356,7 +355,6 @@ const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
@ -465,59 +463,59 @@ const files = computed(() => {
})
const currentFileDetails = computed(() => {
if (state.currentFileId) {
const item = find(state.fileList, ['id', state.currentFileId])
if (item.type === 'folder') {
return null
}
if (!state.currentFileId) {
return null
}
const item = state.fileList.find(f => f.id === state.currentFileId)
if (!item || item.type === 'folder') {
return null
}
const items = [
{
label: t('fileman.detailsTitle'),
value: item.title
}
]
let thumbnail = ''
switch (item.type) {
case 'page': {
thumbnail = '/_assets/illustrations/fileman-page.svg'
items.push({
label: t('fileman.detailsPageType'),
value: t(`fileman.${item.pageType}PageType`)
})
items.push({
label: t('fileman.detailsPageEditor'),
value: item.pageType
})
items.push({
label: t('fileman.detailsPageUpdated'),
value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
})
items.push({
label: t('fileman.detailsPageCreated'),
value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
})
break
}
case 'asset': {
thumbnail = `/_thumb/${item.id}.webp`
items.push({
label: t('fileman.detailsAssetType'),
value: fileTypes[item.fileExt] ? t(`fileman.${item.fileExt}FileType`) : t('fileman.unknownFileType', { type: item.fileExt.toUpperCase() })
})
items.push({
label: t('fileman.detailsAssetSize'),
value: filesize(item.fileSize)
})
break
}
const items = [
{
label: t('fileman.detailsTitle'),
value: item.title
}
return {
thumbnail,
items
]
let thumbnail = null
switch (item.type) {
case 'page': {
thumbnail = '/_assets/illustrations/fileman-page.svg'
items.push({
label: t('fileman.detailsPageType'),
value: t(`fileman.${item.pageType}PageType`)
})
items.push({
label: t('fileman.detailsPageEditor'),
value: item.pageType
})
items.push({
label: t('fileman.detailsPageUpdated'),
value: formatDateTime(item.updatedAt)
})
items.push({
label: t('fileman.detailsPageCreated'),
value: formatDateTime(item.createdAt)
})
break
}
} else {
return null
case 'asset': {
// -> Only images get one, and the endpoint answers 404 for anything else
thumbnail = item.mimeType?.startsWith('image/') ? `/_thumb/${item.id}.webp` : null
items.push({
label: t('fileman.detailsAssetType'),
value: fileTypes[item.fileExt] ? t(`fileman.${item.fileExt}FileType`) : t('fileman.unknownFileType', { type: item.fileExt.toUpperCase() })
})
items.push({
label: t('fileman.detailsAssetSize'),
value: filesize(item.fileSize)
})
break
}
}
return {
thumbnail,
items
}
})
@ -533,9 +531,27 @@ function close () {
siteStore.overlay = null
}
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage (err, fallback) {
const message = await err.response?.json().then(b => b?.message).catch(() => null)
return message || err.message || fallback
}
function formatDateTime (value) {
if (!value) {
return ''
}
return Temporal.Instant.from(value)
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toLocaleString(commonStore.locale, { dateStyle: 'medium', timeStyle: 'short' })
}
function insertItem (item) {
if (!item) {
item = find(state.fileList, ['id', state.currentFileId])
item = state.fileList.find(f => f.id === state.currentFileId)
}
EVENT_BUS.emit('insertAsset', toRaw(item))
close()
@ -558,64 +574,20 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
state.fileList = []
}
try {
const resp = await APOLLO_CLIENT.query({
query: `
query loadTree (
$siteId: UUID!
$parentId: UUID
$parentPath: String
$types: [TreeItemType]
$includeAncestors: Boolean
$includeRootFolders: Boolean
) {
tree (
siteId: $siteId
parentId: $parentId
parentPath: $parentPath
types: $types
includeAncestors: $includeAncestors
includeRootFolders: $includeRootFolders
) {
__typename
id
folderPath
fileName
title
... on TreeItemFolder {
childrenCount
isAncestor
}
... on TreeItemPage {
createdAt
updatedAt
editor
}
... on TreeItemAsset {
createdAt
updatedAt
fileSize
fileExt
mimeType
}
}
}
`,
variables: {
siteId: siteStore.id,
parentId,
parentPath,
types,
const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: {
...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}),
...(types?.length > 0 ? { types: types.join(',') } : {}),
includeAncestors: initLoad,
includeRootFolders: initLoad
},
fetchPolicy: 'network-only'
})
const items = cloneDeep(resp?.data?.tree)
}
}).json()
if (items?.length > 0) {
const newTreeRoots = []
for (const item of items) {
switch (item.__typename) {
case 'TreeItemFolder': {
switch (item.type) {
case 'folder': {
// -> Tree Nodes
state.treeNodes[item.id] = {
folderPath: item.folderPath,
@ -629,8 +601,11 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
let folderParentId = parentId
if (!folderParentId) {
const parentFolderParts = item.folderPath.split('/')
const parentFolder = find(items, { folderPath: parentFolderParts.length > 1 ? initial(parentFolderParts).join('/') : '', fileName: last(parentFolderParts) })
folderParentId = parentFolder.id
const parentFolder = items.find(i =>
i.folderPath === parentFolderParts.slice(0, -1).join('/') &&
i.fileName === parentFolderParts.at(-1)
)
folderParentId = parentFolder?.id
}
if (item.id !== folderParentId && !state.treeNodes[folderParentId]?.children?.includes(item.id)) {
state.treeNodes[folderParentId]?.children?.push(item.id)
@ -651,7 +626,7 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
}
break
}
case 'TreeItemAsset': {
case 'asset': {
if (parentId === state.currentFolderId) {
state.fileList.push({
id: item.id,
@ -661,21 +636,24 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
fileSize: item.fileSize,
mimeType: item.mimeType,
folderPath: item.folderPath,
fileName: item.fileName
fileName: item.fileName,
createdAt: item.createdAt,
updatedAt: item.updatedAt
})
}
break
}
case 'TreeItemPage': {
case 'page': {
if (parentId === state.currentFolderId) {
state.fileList.push({
id: item.id,
type: 'page',
title: item.title,
pageType: 'markdown',
updatedAt: '2022-11-24T18:27:00Z',
pageType: item.editor || 'markdown',
folderPath: item.folderPath,
fileName: item.fileName
fileName: item.fileName,
createdAt: item.createdAt,
updatedAt: item.updatedAt
})
}
break
@ -690,7 +668,7 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
$q.notify({
type: 'negative',
message: 'Failed to load folder tree.',
caption: err.message
caption: await apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId === state.currentFolderId) {
@ -856,6 +834,7 @@ async function uploadNewFiles () {
}
state.isUploading = true
state.shouldCancelUpload = false
state.uploadPercentage = 0
state.loading++
@ -863,57 +842,47 @@ async function uploadNewFiles () {
nextTick(() => {
setTimeout(async () => {
try {
const totalFiles = fileIpt.value.files.length
const filesToUpload = [...fileIpt.value.files]
const totalFiles = filesToUpload.length
let idx = 0
for (const fileToUpload of fileIpt.value.files) {
for (const fileToUpload of filesToUpload) {
// -> A cancel can only take effect between files: a request already in flight is left to
// finish, since the server has the bytes either way
if (state.shouldCancelUpload) {
break
}
idx++
state.uploadPercentage = totalFiles > 1 ? Math.round(idx / totalFiles * 100) : 90
const resp = await APOLLO_CLIENT.mutate({
context: {
uploadMode: true
// -> The body is the file itself rather than a multipart form, and the locale is left to the
// server, which uses the site's primary one
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, {
searchParams: {
fileName: fileToUpload.name,
...(state.currentFolderId ? { folderId: state.currentFolderId } : {})
},
mutation: `
mutation uploadAssets (
$folderId: UUID
$locale: String
$siteId: UUID
$files: [Upload!]!
) {
uploadAssets (
folderId: $folderId
locale: $locale
siteId: $siteId
files: $files
) {
operation {
succeeded
message
}
}
}
`,
variables: {
folderId: state.currentFolderId,
siteId: siteStore.id,
locale: 'en', // TODO: use current locale
files: [fileToUpload]
}
})
if (!resp?.data?.uploadAssets?.operation?.succeeded) {
throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
headers: {
'content-type': fileToUpload.type || 'application/octet-stream'
},
body: fileToUpload
}).json()
// -> The API client does not throw on 400, so a refused file comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
}
state.uploadPercentage = 100
loadTree({ parentId: state.currentFolderId })
$q.notify({
type: 'positive',
message: t('fileman.uploadSuccess')
})
if (!state.shouldCancelUpload) {
$q.notify({
type: 'positive',
message: t('fileman.uploadSuccess')
})
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to upload file.',
caption: err.message
caption: await apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.loading--
@ -927,8 +896,7 @@ async function uploadNewFiles () {
}
function uploadCancel () {
state.isUploading = false
state.uploadPercentage = 0
state.shouldCancelUpload = true
}
// --------------------------------------
@ -1006,12 +974,27 @@ async function editItem (item) {
close()
}
function downloadItem (item) {
async function downloadItem (item) {
try {
// -> Fetched rather than linked to: the content route is behind the API client, which is what
// carries the token
const blob = await API_CLIENT.get(`sites/${siteStore.id}/assets/${item.id}/content`).blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = item.fileName
link.click()
URL.revokeObjectURL(url)
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to download file.',
caption: await apiErrorMessage(err, 'An unexpected error occured.')
})
}
}
function renameItem (item) {
console.info(item)
switch (item.type) {
case 'folder': {
renameFolder(item.id)
@ -1049,7 +1032,7 @@ function delItem (item) {
onMounted(async () => {
const pathParts = pageStore.path.split('/')
const parentPath = initial(pathParts).join('/')
const parentPath = pathParts.slice(0, -1).join('/')
await loadTree({
parentPath,
@ -1057,8 +1040,8 @@ onMounted(async () => {
})
// -> Open tree up to current folder
const folderFolderPath = dropRight(pathParts, 2).join('/')
const folderFileName = nth(pathParts, -2)
const folderFolderPath = pathParts.slice(0, -2).join('/')
const folderFileName = pathParts.at(-2)
for (const [id, node] of Object.entries(state.treeNodes)) {
if (parentPath.startsWith(node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName)) {
@ -1067,9 +1050,10 @@ onMounted(async () => {
}
// -> Switch to current folder (from page path)
const currentNodeId = findKey(state.treeNodes, n => n.folderPath === folderFolderPath && n.fileName === folderFileName)
if (currentNodeId) {
state.currentFolderId = currentNodeId
const currentNode = Object.entries(state.treeNodes)
.find(([, n]) => n.folderPath === folderFolderPath && n.fileName === folderFileName)
if (currentNode) {
state.currentFolderId = currentNode[0]
}
})

@ -139,50 +139,29 @@ async function create () {
if (!isFormValid) {
throw new Error(t('fileman.createFolderInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation createFolder (
$siteId: UUID!
$locale: String!
$parentId: UUID
$pathName: String!
$title: String!
) {
createFolder (
siteId: $siteId
locale: $locale
parentId: $parentId
pathName: $pathName
title: $title
) {
operation {
succeeded
message
}
}
}
`,
variables: {
siteId: siteStore.id,
locale: 'en',
// -> No locale is sent: the server puts the folder in the site's primary one
const resp = await API_CLIENT.post(`sites/${siteStore.id}/tree/folders`, {
json: {
parentId: props.parentId,
pathName: state.path,
title: state.title
}
})
if (resp?.data?.createFolder?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.createFolderSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.createFolder?.operation?.message || 'An unexpected error occured.')
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('fileman.createFolderSuccess')
})
onDialogOK()
} catch (err) {
// -> ky throws above 400 a name already taken in this folder answers 409
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.loading--

@ -35,6 +35,8 @@ import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
// PROPS
const props = defineProps({
@ -59,6 +61,10 @@ defineEmits([
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -74,34 +80,18 @@ const state = reactive({
async function confirm () {
state.isLoading = true
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deleteFolder ($id: UUID!) {
deleteFolder(folderId: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.folderId
}
await API_CLIENT.delete(`sites/${siteStore.id}/tree/folders/${props.folderId}`)
$q.notify({
type: 'positive',
message: t('folderDeleteDialog.deleteSuccess')
})
if (resp?.data?.deleteFolder?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('folderDeleteDialog.deleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deleteFolder?.operation?.message || 'An unexpected error occured.')
}
onDialogOK()
} catch (err) {
// -> ky throws above 400 a folder deleted from another tab answers 404
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.isLoading = false

@ -141,44 +141,27 @@ async function rename () {
if (!isFormValid) {
throw new Error(t('fileman.renameFolderInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation renameFolder (
$folderId: UUID!
$pathName: String!
$title: String!
) {
renameFolder (
folderId: $folderId
pathName: $pathName
title: $title
) {
operation {
succeeded
message
}
}
}
`,
variables: {
folderId: props.folderId,
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/tree/folders/${props.folderId}`, {
json: {
pathName: state.path,
title: state.title
}
})
if (resp?.data?.renameFolder?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.renameFolderSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.renameFolder?.operation?.message || 'An unexpected error occured.')
}).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('fileman.renameFolderSuccess')
})
onDialogOK()
} catch (err) {
// -> ky throws above 400 a name already taken alongside this folder answers 409
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.loading--
@ -189,36 +172,18 @@ async function rename () {
onMounted(async () => {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: `
query fetchFolderForRename (
$id: UUID!
) {
folderById (
id: $id
) {
id
folderPath
fileName
title
}
}
`,
fetchPolicy: 'network-only',
variables: {
id: props.folderId
}
})
if (resp?.data?.folderById?.id !== props.folderId) {
const folder = await API_CLIENT.get(`sites/${siteStore.id}/tree/folders/${props.folderId}`).json()
if (folder?.id !== props.folderId) {
throw new Error('Failed to fetch folder data.')
}
state.path = resp.data.folderById.fileName
state.title = resp.data.folderById.title
state.path = folder.fileName
state.title = folder.title
state.pathDirty = true
} catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
onDialogCancel()
}

@ -150,52 +150,30 @@ function startEditing () {
async function save () {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation updateNavMode (
$pageId: UUID!
$mode: NavigationMode!
) {
updateNavigation (
pageId: $pageId
mode: $mode
) {
operation {
succeeded
message
}
navigationId
}
}
`,
variables: {
pageId: pageStore.id,
mode: state.mode
}
})
if (resp?.data?.updateNavigation?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('navEdit.saveModeSuccess')
})
// -> Clear GraphQL Cache
APOLLO_CLIENT.cache.evict('ROOT_QUERY')
APOLLO_CLIENT.cache.gc()
// -> Set current nav id
pageStore.$patch({
navigationMode: state.mode,
navigationId: resp.data.updateNavigation.navigationId
})
props.menuHideHandler()
} else {
throw new Error(resp?.data?.updateNavigation?.operation?.message || 'Unexpected error occured.')
// -> Only the mode: the menu items themselves are what the overlay saves
const resp = await API_CLIENT.put(
`sites/${siteStore.id}/navigation/pages/${pageStore.id}`,
{ json: { mode: state.mode } }
).json()
// -> The API client does not throw on 400, so a refusal comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('navEdit.saveModeSuccess')
})
// -> Patching the id is what makes the sidebar reload: it watches this and refetches the menu the
// page now resolves to
pageStore.$patch({
navigationMode: state.mode,
navigationId: resp.navigationId ?? null
})
props.menuHideHandler()
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
message: await err.response?.json().then(b => b?.message).catch(() => null) || err.message
})
}
state.loading--

@ -384,10 +384,10 @@ q-layout(view='hHh lpR fFf', container)
<script setup>
import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { v4 as uuid } from 'uuid'
import { cloneDeep, last, pick } from 'lodash-es'
import { pick } from 'es-toolkit/object'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
@ -427,6 +427,15 @@ const state = reactive({
groups: []
})
/**
* The icon a new link item starts with.
*
* An Iconify reference, so that the icon picker opens on its search tab with this one selected, and so
* that the sidebar draws it through `wiki-icon` like every other item. Kept to `mdi`, a set seeded on
* every instance.
*/
const DEFAULT_LINK_ICON = 'mdi:text-box-outline'
const sortableOptions = {
handle: '.handle',
animation: 150
@ -437,6 +446,17 @@ const visibilityOptions = [
{ value: true, label: t('navEdit.visibilityLimited') }
]
// COMPUTED
/**
* The menu being edited.
*
* The home page edits the site-wide menu the one every other page inherits which is why it goes
* through its resolved id rather than its own. Any other page owns a menu keyed by its own id, which
* the server creates on the first save.
*/
const navId = computed(() => (pageStore.isHome ? pageStore.navigationId : pageStore.id))
const thumbStyle = {
right: '2px',
borderRadius: '5px',
@ -471,7 +491,7 @@ function addItem (type) {
}
case 'link': {
newItem.label = t('navEdit.link')
newItem.icon = 'mdi-text-box-outline'
newItem.icon = DEFAULT_LINK_ICON
newItem.target = '/'
newItem.openInNewWindow = false
newItem.isNested = false
@ -504,20 +524,28 @@ function close () {
siteStore.$patch({ overlay: '' })
}
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage (err) {
const message = await err.response?.json().then(b => b?.message).catch(() => null)
return message || err.message || 'An unexpected error occured.'
}
async function loadGroups () {
state.loading++
const resp = await APOLLO_CLIENT.query({
query: `
query getGroupsForEditNavMenu {
groups {
id
name
}
}
`,
fetchPolicy: 'network-only'
})
state.groups = cloneDeep(resp?.data?.groups ?? [])
try {
const groups = await API_CLIENT.get('groups').json()
state.groups = (groups ?? []).map(g => ({ id: g.id, name: g.name }))
} catch (err) {
// -> Without the list, per-group visibility cannot be set, but the rest of the editor still works
$q.notify({
type: 'warning',
message: t('navEdit.groupsFailed'),
caption: await apiErrorMessage(err)
})
}
state.loading--
}
@ -525,39 +553,13 @@ async function loadMenuItems () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getItemsForEditNavMenu (
$id: UUID!
) {
navigationById (
id: $id
) {
id
type
label
icon
target
openInNewWindow
visibilityGroups
children {
id
type
label
icon
target
openInNewWindow
visibilityGroups
}
}
}
`,
variables: {
id: pageStore.isHome ? pageStore.navigationId : pageStore.id
},
fetchPolicy: 'network-only'
})
for (const item of cloneDeep(resp?.data?.navigationById ?? [])) {
// -> `full`, because the editor has to see items limited to groups the editor is not in: saving
// without them would delete them
const items = await API_CLIENT.get(
`sites/${siteStore.id}/navigation/${navId.value}`,
{ searchParams: { full: true } }
).json()
for (const item of items ?? []) {
state.items.push({
...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow', 'visibilityGroups']),
visibilityLimited: item.visibilityGroups?.length > 0
@ -565,16 +567,15 @@ async function loadMenuItems () {
for (const child of (item?.children ?? [])) {
state.items.push({
...pick(child, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow', 'visibilityGroups']),
visibilityLimited: item.visibilityGroups?.length > 0,
visibilityLimited: child.visibilityGroups?.length > 0,
isNested: true
})
}
}
} catch (err) {
console.error(err)
$q.notify({
type: 'negative',
message: err.message
message: await apiErrorMessage(err)
})
close()
}
@ -613,7 +614,7 @@ async function save () {
const items = []
for (const item of state.items) {
if (item.isNested) {
if (items.length < 1 || last(items)?.type !== 'link') {
if (items.length < 1 || items.at(-1)?.type !== 'link') {
throw new Error('One or more nested link items are not under a parent link!')
}
items[items.length - 1].children.push(cleanMenuItem(item, true))
@ -622,48 +623,36 @@ async function save () {
}
}
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation updateMenuItems (
$pageId: UUID!
$mode: NavigationMode!
$items: [NavigationItemInput!]
) {
updateNavigation (
pageId: $pageId
mode: $mode
items: $items
) {
operation {
succeeded
message
}
}
// -> The mode goes with the items: saving a menu for a page that only inherits would store items
// nothing points at
const resp = await API_CLIENT.put(
`sites/${siteStore.id}/navigation/pages/${pageStore.id}`,
{
json: {
mode: siteStore.overlayOpts.mode ?? pageStore.navigationMode,
items
}
`,
variables: {
pageId: pageStore.id,
mode: siteStore.overlayOpts.mode,
items
}
})
if (resp?.data?.updateNavigation?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('navEdit.saveSuccess')
})
siteStore.nav.items = items
// -> Clear GraphQL Cache
APOLLO_CLIENT.cache.evict('ROOT_QUERY')
APOLLO_CLIENT.cache.gc()
close()
} else {
throw new Error(resp?.data?.updateNavigation?.operation?.message || 'Unexpected error occured.')
).json()
// -> The API client does not throw on 400, so a refusal comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('navEdit.saveSuccess')
})
pageStore.$patch({
navigationMode: resp.navigationMode,
navigationId: resp.navigationId ?? null
})
// -> Redraw the sidebar from what was just saved, rather than waiting for a navigation
await siteStore.fetchNavigation(resp.navigationId ?? navId.value)
close()
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
message: await apiErrorMessage(err)
})
}
$q.loading.hide()

@ -35,6 +35,8 @@ import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
// PROPS
const props = defineProps({
@ -59,6 +61,10 @@ defineEmits([
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
@ -74,34 +80,18 @@ const state = reactive({
async function confirm () {
state.isLoading = true
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation deletePage ($id: UUID!) {
deletePage(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.pageId
}
await API_CLIENT.delete(`sites/${siteStore.id}/pages/${props.pageId}`)
$q.notify({
type: 'positive',
message: t('pageDeleteDialog.deleteSuccess')
})
if (resp?.data?.deletePage?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('pageDeleteDialog.deleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deletePage?.operation?.message || 'An unexpected error occured.')
}
onDialogOK()
} catch (err) {
// -> ky throws above 400 a page deleted from another tab answers 404
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
}
state.isLoading = false

@ -57,14 +57,17 @@ q-card.page-properties-dialog
outlined
dense
)
template(#prepend)
wiki-icon(:name='pageStore.icon', size='20px', color='primary')
template(#append)
q-icon.cursor-pointer(
name='las la-icons'
color='primary'
:aria-label='t(`editor.props.selectIcon`)'
)
q-menu(content-class='shadow-7')
.q-pa-lg: em [ TODO: Icon Picker Dialog ]
// icon-picker-dialog(v-model='pageStore.icon')
//- The properties panel is docked to the right edge, so the picker has to grow leftwards
q-menu(content-class='shadow-7', anchor='bottom right', self='top right')
icon-picker-dialog(v-model='pageStore.icon')
q-input(
v-if='pageStore.path !== `home`'
v-model='pageStore.alias'
@ -315,8 +318,8 @@ q-card.page-properties-dialog
import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { DateTime } from 'luxon'
import IconPickerDialog from './IconPickerDialog.vue'
import PageRelationDialog from './PageRelationDialog.vue'
import PageScriptsDialog from './PageScriptsDialog.vue'
import PageTags from './PageTags.vue'
@ -384,7 +387,7 @@ const publishingRange = computed({
pageStore.$subscribe(() => {
editorStore.$patch({
lastChangeTimestamp: DateTime.utc()
lastChangeTimestamp: Temporal.Now.instant()
})
})

@ -2,7 +2,7 @@
q-layout(view='hHh lpR fFf', container)
q-header.card-header.q-px-md.q-py-sm
q-icon(name='img:/_assets/icons/fluent-code.svg', left, size='md')
span Page Source
span {{ t('pageSource.title') }}
q-space
transition(name='syncing')
q-spinner-tail.q-mr-sm(
@ -15,6 +15,7 @@ q-layout(view='hHh lpR fFf', container)
color='teal-3'
dense
flat
:disable='!state.content'
@click='download'
)
q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.download`)}}
@ -35,15 +36,14 @@ q-layout(view='hHh lpR fFf', container)
:horizontal-thumb-style='{ height: `5px` }'
style="width: 100%; height: calc(100vh - 100px);"
)
pre.q-px-md(v-text='state.content')
.q-pa-md.text-grey-5(v-if='state.notice') {{ state.notice }}
pre.q-px-md(v-else, v-text='state.content')
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { exportFile, useQuasar } from 'quasar'
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { cloneDeep } from 'lodash-es'
import { onBeforeUnmount, onMounted, reactive } from 'vue'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
@ -65,7 +65,9 @@ const { t } = useI18n()
const state = reactive({
loading: 0,
content: ''
content: '',
contentType: '',
notice: ''
})
const thumb = {
@ -107,39 +109,36 @@ async function load () {
state.loading++
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query loadPageSource (
$id: UUID!
) {
pageById(
id: $id
) {
id
content
contentType
}
}
`,
variables: {
id: pageStore.id
},
fetchPolicy: 'network-only'
})
const pageData = cloneDeep(resp?.data?.pageById ?? {})
// -> The source is not part of an ordinary page load, so it has to be asked for
const pageData = await API_CLIENT.get(`sites/${siteStore.id}/pages/${pageStore.id}`, {
searchParams: { withContent: true }
}).json()
if (!pageData?.id) {
throw new Error('ERR_PAGE_NOT_FOUND')
throw new Error(t('pageSource.notFound'))
}
// -> The source is withheld from a reader without a session, the field being left out entirely
// rather than blanked an empty string is a page that genuinely has no content
if (pageData.content === undefined) {
state.notice = t('pageSource.unavailable')
return
}
state.content = pageData.content
state.contentType = pageData.contentType
// -> Falls back to the editor, which is what identifies the format for anything written before
// contentType was stored
state.contentType = pageData.contentType || pageData.editor || ''
} catch (err) {
const message = err.response?.status === 404
? t('pageSource.notFound')
: await err.response?.json().then(b => b?.message).catch(() => null) || err.message
state.notice = message
$q.notify({
type: 'negative',
message: err.message
message
})
} finally {
$q.loading.hide()
state.loading--
}
$q.loading.hide()
state.loading--
}
onMounted(() => {

@ -30,7 +30,8 @@
new-value-mode='add-unique'
@new-value='createTag'
@filter='filterTags'
placeholder='Select or create tags...'
:placeholder='t(`editor.props.tagsPlaceholder`)'
:aria-label='t(`editor.props.tags`)'
:loading='state.loading'
)
template(v-slot:option='scope')
@ -46,7 +47,6 @@
import { useQuasar } from 'quasar'
import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { DateTime } from 'luxon'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
@ -88,16 +88,26 @@ const state = reactive({
pageStore.$subscribe(() => {
if (props.edit) {
editorStore.$patch({
lastChangeTimestamp: DateTime.utc()
lastChangeTimestamp: Temporal.Now.instant()
})
}
})
watch(() => props.edit, async (newValue) => {
if (newValue) {
state.loading = true
if (!newValue) { return }
state.loading = true
try {
await siteStore.fetchTags()
state.tags = siteStore.tags.map(t => t.tag)
} catch (err) {
// -> Suggestions are a convenience: without them the field still adds tags, so this is a warning
// rather than a failure, and the spinner must not be left running either way
$q.notify({
type: 'warning',
message: t('editor.props.tagsFailed'),
caption: await err.response?.json().then(b => b?.message).catch(() => null) || err.message
})
} finally {
state.loading = false
}
}, { immediate: true })

@ -9,8 +9,10 @@ q-dialog(ref='dialogRef', @hide='onDialogHide' position='bottom', persistent)
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { computed, onMounted, reactive } from 'vue'
import { onMounted } from 'vue'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
// PROPS
@ -35,6 +37,7 @@ const $q = useQuasar()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
@ -45,38 +48,27 @@ const { t } = useI18n()
async function rerenderPage () {
await new Promise(resolve => setTimeout(resolve, 1000)) // allow for dialog to show
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation rerenderPage(
$id: UUID!
) {
rerenderPage (
id: $id
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.id
}
})
if (resp?.data?.rerenderPage?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('renderPageDialog.success')
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages/${props.id}/render`).json()
// -> The page currently on screen is the one that was re-rendered, so show the new render rather
// than leaving the stale one until the next navigation
if (resp?.page?.id === pageStore.id) {
pageStore.$patch({
render: resp.page.render,
toc: resp.page.toc
})
onDialogOK()
} else {
throw new Error(resp?.data?.rerenderPage?.operation?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('renderPageDialog.success')
})
onDialogOK()
} catch (err) {
// -> ky throws above 400 without the Puppeteer extension the server answers 503, since it has
// no way to run the renderer
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
onDialogCancel()
}

@ -19,6 +19,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
)
.q-px-sm
tree(
ref='treeComp'
:nodes='state.treeNodes'
:roots='state.treeRoots'
v-model:selected='state.currentFolderId'
@ -49,21 +50,25 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
q-item-section
q-input(
v-model='state.title'
label='Page Title'
:label='t(`pageSaveDialog.pageTitle`)'
:aria-label='t(`pageSaveDialog.pageTitle`)'
dense
outlined
autofocus
@focus='state.currentFileId = null'
@keyup.enter='save'
)
q-item
blueprint-icon(icon='file-submodule')
q-item-section
q-input(
v-model='state.path'
label='Path Name'
:label='t(`pageSaveDialog.pathName`)'
:aria-label='t(`pageSaveDialog.pathName`)'
dense
outlined
@focus='state.pathDirty = true; state.currentFileId = null'
@keyup.enter='save'
)
//- template(#append)
//- q-badge(outline, color='grey', label='valid')
@ -116,15 +121,13 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
color='primary'
padding='xs md'
@click='save'
v-close-popup
)
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { cloneDeep, find, initial, last } from 'lodash-es'
import slugify from 'slugify'
@ -133,9 +136,7 @@ import fileTypes from '../helpers/fileTypes'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import Tree from '@/components/TreeNav.vue'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { dropRight } from 'lodash'
// PROPS
@ -180,7 +181,6 @@ const $q = useQuasar()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
@ -191,8 +191,9 @@ const { t } = useI18n()
const state = reactive({
displayMode: 'title',
currentFolderId: '',
currentFileId: '',
currentFolderId: null,
currentFileId: null,
isFetching: false,
treeNodes: {},
treeRoots: [],
fileList: [],
@ -213,15 +214,18 @@ const barStyle = {
width: '7px'
}
// REFS
const treeComp = ref(null)
// COMPUTED
const currentFolderPath = computed(() => {
if (!state.currentFolderId) {
const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null
if (!folderNode?.fileName) {
return '/'
} else {
const folderNode = state.treeNodes[state.currentFolderId] ?? {}
return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
}
return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
})
const files = computed(() => {
@ -242,6 +246,10 @@ const files = computed(() => {
// WATCHERS
watch(() => state.currentFolderId, async (newValue) => {
await loadTree({ parentId: newValue })
})
watch(() => state.title, (newValue) => {
if (state.pathDirty && !state.path) {
state.pathDirty = false
@ -254,108 +262,123 @@ watch(() => state.title, (newValue) => {
// METHODS
async function save () {
if (!state.title?.trim()) {
$q.notify({
type: 'negative',
message: t('pageSaveDialog.titleMissing')
})
return
}
if (!/^[a-z0-9-]+$/.test(state.path)) {
$q.notify({
type: 'negative',
message: t('pageSaveDialog.pathInvalid')
})
return
}
onDialogOK({
title: state.title,
title: state.title.trim(),
path: currentFolderPath.value.length > 1 ? `${currentFolderPath.value.substring(1)}${state.path}` : state.path
})
}
async function treeLazyLoad (nodeId, isCurrent, { done, fail }) {
await loadTree({
parentId: nodeId,
types: ['folder', 'page']
})
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage (err, fallback) {
const message = await err.response?.json().then(b => b?.message).catch(() => null)
return message || err.message || fallback
}
async function treeLazyLoad (nodeId, isCurrent, { done }) {
await loadTree({ parentId: nodeId })
done()
}
async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
try {
/**
* Loads one folder into the tree, and when that folder is the selected one into the file list.
*
* `initLoad` asks for the folders above the one being listed as well, so that opening the dialog on a
* page buried a few levels down draws its whole branch from a single request. Those extra entries come
* back flagged `isAncestor` and belong in the tree only, never in the file list.
*/
async function loadTree ({ parentId = null, parentPath = null, initLoad = false }) {
if (state.isFetching) { return }
state.isFetching = true
if (!parentId) {
parentId = null
}
const isCurrentFolder = parentId === state.currentFolderId
if (isCurrentFolder) {
state.currentFileId = null
state.fileList = []
const resp = await APOLLO_CLIENT.query({
query: `
query loadTree (
$siteId: UUID!
$parentId: UUID
$parentPath: String
$types: [TreeItemType]
$includeAncestors: Boolean
$includeRootFolders: Boolean
) {
tree (
siteId: $siteId
parentId: $parentId
parentPath: $parentPath
types: $types
includeAncestors: $includeAncestors
includeRootFolders: $includeRootFolders
) {
__typename
... on TreeItemFolder {
id
folderPath
fileName
title
childrenCount
}
... on TreeItemPage {
id
folderPath
fileName
title
createdAt
updatedAt
editor
}
}
}
`,
variables: {
siteId: siteStore.id,
parentId,
parentPath,
types,
}
try {
const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: {
...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}),
...(state.typesToFetch?.length > 0 ? { types: state.typesToFetch.join(',') } : {}),
includeAncestors: initLoad,
includeRootFolders: initLoad
},
fetchPolicy: 'network-only'
})
const items = cloneDeep(resp?.data?.tree)
}
}).json()
if (items?.length > 0) {
const newTreeRoots = []
for (const item of items) {
switch (item.__typename) {
case 'TreeItemFolder': {
switch (item.type) {
case 'folder': {
// -> Tree Nodes
state.treeNodes[item.id] = {
folderPath: item.folderPath,
fileName: item.fileName,
title: item.title,
children: []
children: state.treeNodes[item.id]?.children ?? []
}
// -> Set Ancestors / Tree Roots
if (item.folderPath) {
let folderParentId = parentId
if (!folderParentId) {
const parentFolderParts = item.folderPath.split('/')
const parentFolder = find(items, { folderPath: parentFolderParts.length > 1 ? initial(parentFolderParts).join('/') : '', fileName: last(parentFolderParts) })
folderParentId = parentFolder.id
const parentFolder = items.find(i =>
i.folderPath === parentFolderParts.slice(0, -1).join('/') &&
i.fileName === parentFolderParts.at(-1)
)
folderParentId = parentFolder?.id
}
if (item.id !== folderParentId && !state.treeNodes[folderParentId]?.children?.includes(item.id)) {
state.treeNodes[folderParentId].children.push(item.id)
state.treeNodes[folderParentId]?.children?.push(item.id)
}
} else {
newTreeRoots.push(item.id)
}
// -> File List
if (isCurrentFolder && !item.isAncestor) {
state.fileList.push({
id: item.id,
type: 'folder',
title: item.title,
fileName: item.fileName
})
}
break
}
case 'TreeItemPage': {
state.fileList.push({
id: item.id,
type: 'page',
title: item.title,
pageType: 'markdown',
updatedAt: '2022-11-24T18:27:00Z',
folderPath: item.folderPath,
fileName: item.fileName
})
case 'page': {
if (isCurrentFolder) {
state.fileList.push({
id: item.id,
type: 'page',
title: item.title,
pageType: item.editor || 'markdown',
folderPath: item.folderPath,
fileName: item.fileName,
createdAt: item.createdAt,
updatedAt: item.updatedAt
})
}
break
}
}
@ -367,10 +390,14 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to load folder tree.',
caption: err.message
message: t('pageSaveDialog.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId) {
treeComp.value?.setLoaded(parentId)
}
state.isFetching = false
}
function treeContextAction (nodeId, action) {
@ -383,7 +410,14 @@ function treeContextAction (nodeId, action) {
}
function selectItem (item) {
// -> A folder is somewhere to save into, not something to overwrite
if (item.type === 'folder') {
state.currentFolderId = item.id
treeComp.value?.setOpened(item.id)
return
}
state.currentFileId = item.id
state.pathDirty = true
state.title = item.title
state.path = item.fileName
}
@ -399,21 +433,27 @@ function newFolder (parentId) {
})
}
/** The id of an already-loaded folder, addressed the way a path addresses it. */
function findFolderIdByPath (path) {
if (!path) { return null }
const entry = Object.entries(state.treeNodes).find(([, node]) =>
(node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) === path
)
return entry?.[0] ?? null
}
// MOUNTED
onMounted(() => {
onMounted(async () => {
let fPath = props.folderPath
let fName = props.itemFileName
if (props.itemFileName?.indexOf('/') >= 0) {
if (props.itemFileName?.includes('/')) {
const fParts = props.itemFileName.split('/')
fPath = dropRight(fParts, 1).join('/')
fName = last(fParts)
fPath = fParts.slice(0, -1).join('/')
fName = fParts.at(-1)
}
switch (props.mode) {
case 'savePage': {
state.typesToFetch = ['folder', 'page']
break
}
case 'savePage':
case 'duplicatePage': {
state.typesToFetch = ['folder', 'page']
break
@ -424,13 +464,25 @@ onMounted(() => {
break
}
}
loadTree({
state.title = props.itemTitle || ''
state.path = fName || ''
await loadTree({
parentPath: fPath,
types: state.typesToFetch,
initLoad: true
})
state.title = props.itemTitle || ''
state.path = fName || ''
// -> A page that lives in a subfolder opens the browser on that subfolder rather than on the root.
// The initial request asked for the ancestors too, so the whole branch is already here.
const startFolderId = findFolderIdByPath(fPath)
if (startFolderId) {
const parts = fPath.split('/')
for (let i = 1; i <= parts.length; i++) {
const ancestorId = findFolderIdByPath(parts.slice(0, i).join('/'))
if (ancestorId) {
treeComp.value?.setOpened(ancestorId)
}
}
state.currentFolderId = startFolderId
}
})
</script>

@ -64,50 +64,37 @@ onMounted(async () => {
try {
for (const item of editorStore.pendingAssets) {
state.current++
const resp = await APOLLO_CLIENT.mutate({
context: {
uploadMode: true
// -> The body is the file itself rather than a multipart form, and the locale is left to the
// server, which uses the site's primary one
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, {
searchParams: {
fileName: item.fileName
// TODO: Upload to page specific folder
},
mutation: `
mutation uploadAssets (
$folderId: UUID
$locale: String
$siteId: UUID
$files: [Upload!]!
) {
uploadAssets (
folderId: $folderId
locale: $locale
siteId: $siteId
files: $files
) {
operation {
succeeded
message
}
}
}
`,
variables: {
folderId: null, // TODO: Upload to page specific folder
siteId: siteStore.id,
locale: 'en', // TODO: use current locale
files: [item.file]
}
})
if (!resp?.data?.uploadAssets?.operation?.succeeded) {
throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
headers: {
'content-type': item.file.type || 'application/octet-stream'
},
body: item.file
}).json()
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
pageStore.content = pageStore.content.replaceAll(item.blobUrl, `/${item.fileName}`)
// -> The stored name is not always the one asked for: a file already in the folder gets the
// next free `name-1.ext`, and the content has to point at what was actually stored
const storedPath = resp?.asset?.folderPath
? `${resp.asset.folderPath}/${resp.asset.fileName}`
: resp?.asset?.fileName
pageStore.content = pageStore.content.replaceAll(item.blobUrl, `/${storedPath}`)
URL.revokeObjectURL(item.blobUrl)
}
editorStore.pendingAssets = []
EVENT_BUS.emit('reloadEditorContent')
onDialogOK()
} catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({
type: 'negative',
message: err.message
message: apiMessage || err.message
})
onDialogCancel()
}

@ -93,14 +93,25 @@ useMeta({
async function createHomePage (editor) {
$q.loading.show()
siteStore.overlay = ''
await pageStore.pageCreate({
editor,
locale: 'en',
path: 'home',
title: t('welcome.homeDefault.title'),
description: t('welcome.homeDefault.description'),
content: t('welcome.homeDefault.content')
})
try {
await pageStore.pageCreate({
editor,
locale: siteStore.locales.primary,
path: 'home',
title: t('welcome.homeDefault.title'),
description: t('welcome.homeDefault.description'),
content: t('welcome.homeDefault.content')
})
} catch (err) {
// -> Opening the editor is what this button does, so a failure has to be said out loud rather
// than leaving the spinner up over a screen that never changed
siteStore.overlay = 'Welcome'
$q.notify({
type: 'negative',
message: 'Failed to open the editor.',
caption: err.message
})
}
$q.loading.hide()
}

@ -35,6 +35,7 @@ q-layout(view='hHh Lpr lff')
q-tooltip(anchor='center right' self='center left') Bookmarks
q-space
q-btn.q-py-xs(
v-if='canEditNav'
flat
icon='las la-dharmachakra'
color='white'
@ -81,22 +82,23 @@ q-layout(view='hHh Lpr lff')
v-if='userStore.authenticated'
dense
)
q-btn.col(
icon='las la-dharmachakra'
label='Edit Nav'
flat
)
q-menu(
ref='navEditMenu'
anchor='top left'
self='bottom left'
:offset='[0, 10]'
template(v-if='canEditNav')
q-btn.col(
icon='las la-dharmachakra'
label='Edit Nav'
flat
)
nav-edit-menu(
:menu-hide-handler='navEditMenu.hide'
:update-position-handler='navEditMenu.updatePosition'
q-menu(
ref='navEditMenu'
anchor='top left'
self='bottom left'
:offset='[0, 10]'
)
q-separator(vertical)
nav-edit-menu(
:menu-hide-handler='navEditMenu.hide'
:update-position-handler='navEditMenu.updatePosition'
)
q-separator(vertical)
q-btn.col(
icon='las la-bookmark'
label='Bookmarks'
@ -185,6 +187,12 @@ const isSidebarMini = computed(() => {
return ['hide', 'hideExact'].includes(pageStore.navigationMode) || !pageStore.navigationId
})
// -> Saving from this menu needs manage:navigation, so offering it to anyone else only produces a
// permission error once they press Save
const canEditNav = computed(() => {
return userStore.authenticated && userStore.can('manage:navigation')
})
// METHODS
function notImplemented () {

@ -152,7 +152,8 @@ q-layout(view='hHh Lpr lff')
:to='`/` + item.path'
)
q-item-section(avatar)
q-avatar(color='primary' text-color='white' rounded :icon='item.icon')
q-avatar(color='primary' text-color='white' rounded)
wiki-icon(:name='item.icon || defaultPageIcon', size='24px')
q-item-section
q-item-label {{ item.title }}
q-item-label(v-if='item.description', caption) {{ item.description }}
@ -183,12 +184,13 @@ import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, onUnmounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { cloneDeep, debounce, difference } from 'lodash-es'
import { DateTime } from 'luxon'
import { debounce } from 'es-toolkit/function'
import { difference } from 'es-toolkit/array'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { DEFAULT_PAGE_ICON } from '@/stores/page'
import HeaderNav from '@/components/HeaderNav.vue'
import FooterNav from '@/components/FooterNav.vue'
@ -196,6 +198,9 @@ import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
/** How many results one search returns. The API caps this at 100, and there is no pager yet. */
const RESULTS_LIMIT = 100
// QUASAR
const $q = useQuasar()
@ -267,6 +272,8 @@ const publishStates = computed(() => {
const tags = computed(() => siteStore.tags.map(t => t.tag))
const defaultPageIcon = DEFAULT_PAGE_ICON
// WATCHERS
watch(() => route.query, async (newQueryObj) => {
@ -288,7 +295,7 @@ function pageStyle (offset, height) {
}
function humanizeDate (val) {
return DateTime.fromISO(val).toFormat(userStore.preferredDateFormat)
return userStore.formatDateTime(t, val)
}
function setOrderBy (val) {
@ -333,88 +340,61 @@ function syncTags (newSelection) {
}
async function performSearch () {
let q = siteStore.search ?? ''
// -> Extract tags
const queryTags = Array.from(q.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
for (const tag of queryTags) {
q = q.replaceAll(`#${tag}`, '')
}
q = q.trim().replaceAll(/\s\s+/g, ' ')
const filters = {
...(state.params.filterPath ? { path: state.params.filterPath } : {}),
...(queryTags.length > 0 ? { tags: queryTags.join(',') } : {}),
...(state.params.filterLocale.length > 0 ? { locales: state.params.filterLocale.join(',') } : {}),
...(state.params.filterEditor ? { editor: state.params.filterEditor } : {}),
...(state.params.filterPublishState ? { publishState: state.params.filterPublishState } : {})
}
// -> Nothing to go on: the empty state says as much, and asking the server would answer with the
// most recently updated pages, which is not what an empty search box means
if (!q && Object.keys(filters).length < 1) {
state.results = []
state.total = 0
siteStore.searchLastQuery = siteStore.search
siteStore.searchIsLoading = false
return
}
state.loading++
siteStore.searchIsLoading = true
try {
let q = siteStore.search
// -> Extract tags
const queryTags = Array.from(q.matchAll(tagsInQueryRgx)).map(t => t[0].substring(1))
for (const tag of queryTags) {
q = q.replaceAll(`#${tag}`, '')
}
q = q.trim().replaceAll(/\s\s+/g, ' ')
const resp = await APOLLO_CLIENT.query({
query: `
query searchPages (
$siteId: UUID!
$query: String!
$path: String
$locale: [String]
$tags: [String]
$editor: String
$publishState: PagePublishState
$orderBy: PageSearchSort
$orderByDirection: OrderByDirection
$offset: Int
$limit: Int
) {
searchPages(
siteId: $siteId
query: $query
path: $path
locale: $locale
tags: $tags
editor: $editor
publishState: $publishState
orderBy: $orderBy
orderByDirection: $orderByDirection
offset: $offset
limit: $limit
) {
results {
id
path
locale
title
description
icon
tags
updatedAt
relevancy
highlight
}
totalHits
}
}
`,
variables: {
siteId: siteStore.id,
query: q,
path: state.params.filterPath,
tags: queryTags,
locale: state.params.filterLocale,
editor: state.params.filterEditor,
publishState: state.params.filterPublishState || null,
const resp = await API_CLIENT.get(`sites/${siteStore.id}/pages/search`, {
searchParams: {
...(q ? { query: q } : {}),
...filters,
orderBy: state.params.orderBy,
orderByDirection: state.params.orderByDirection
},
fetchPolicy: 'network-only'
})
if (!resp?.data?.searchPages) {
throw new Error('Unexpected error')
}
state.results = cloneDeep(resp.data.searchPages.results).map(r => { r.tags.sort(); return r })
state.total = resp.data.searchPages.totalHits
orderByDirection: state.params.orderByDirection,
// -> There is no pager yet, so this is as deep as the results go
limit: RESULTS_LIMIT
}
}).json()
state.results = (resp?.results ?? []).map(r => ({ ...r, tags: [...(r.tags ?? [])].sort() }))
state.total = resp?.totalHits ?? 0
siteStore.searchLastQuery = siteStore.search
} catch (err) {
state.results = []
state.total = 0
$q.notify({
type: 'negative',
message: 'Failed to perform search query.',
caption: err.message
message: t('search.failed'),
caption: await err.response?.json().then(b => b?.message).catch(() => null) || err.message
})
} finally {
state.loading--
siteStore.searchIsLoading = false
}
siteStore.searchIsLoading = false
}
function goBack () {
@ -427,10 +407,20 @@ function goBack () {
// MOUNTED
onMounted(() => {
onMounted(async () => {
if (!siteStore.search) {
siteStore.searchIsLoading = false
}
// -> The tag filter offers what the wiki actually uses, so the list has to be fetched; without it
// the dropdown is silently empty. Listing tags needs a session, and a reader without one still
// gets to search they just filter by typing `#tag` instead of picking from the list
if (userStore.authenticated) {
try {
await siteStore.fetchTags()
} catch (err) {
console.warn(err)
}
}
})
onUnmounted(() => {

@ -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

@ -72,42 +72,14 @@ export const useEditorStore = defineStore('editor', {
if (!siteStore.id) {
throw new Error('Cannot fetch editors config: Missing Site ID')
}
const resp = await APOLLO_CLIENT.query({
query: `
query fetchEditorConfigs (
$id: UUID!
) {
siteById(
id: $id
) {
id
editors {
asciidoc {
isActive
config
}
markdown {
isActive
config
}
wysiwyg {
isActive
config
}
}
}
}
`,
variables: {
id: siteStore.id
},
fetchPolicy: 'network-only'
})
// -> The editor configs are part of the site config, which is one request rather than a
// dedicated endpoint
const siteInfo = await API_CLIENT.get(`sites/${siteStore.id}`).json()
this.$patch({
editors: {
asciidoc: resp?.data?.siteById?.editors?.asciidoc?.config,
markdown: resp?.data?.siteById?.editors?.markdown?.config,
wysiwyg: resp?.data?.siteById?.editors?.wysiwyg?.config
asciidoc: siteInfo?.editors?.asciidoc?.config ?? {},
markdown: siteInfo?.editors?.markdown?.config ?? {},
wysiwyg: siteInfo?.editors?.wysiwyg?.config ?? {}
},
configIsLoaded: true
})

@ -1,185 +1,18 @@
import { defineStore } from 'pinia'
import { cloneDeep, dropRight, initial, last, pick, transform } from 'lodash-es'
import { DateTime } from 'luxon'
import { pick } from 'es-toolkit/object'
import { useSiteStore } from './site'
import { useEditorStore } from './editor'
const pagePropsFragment = `
fragment PageRead on Page {
alias
allowComments
allowContributions
allowRatings
contentType
createdAt
description
editor
icon
id
isBrowsable
isSearchable
locale
navigationId
navigationMode
password
path
publishEndDate
publishStartDate
publishState
relations {
id
position
label
caption
icon
target
}
render
scriptJsLoad
scriptJsUnload
scriptCss
showSidebar
showTags
showToc
tags
title
toc
tocDepth {
min
max
}
updatedAt
}
`
const gqlQueries = {
pageById: `
query loadPage (
$id: UUID!
) {
pageById(
id: $id
) {
...PageRead
}
}
${pagePropsFragment}
`,
pageByPath: `
query loadPage (
$siteId: UUID!
$path: String!
) {
pageByPath(
siteId: $siteId
path: $path
) {
...PageRead
}
}
${pagePropsFragment}
`,
pageByIdWithContent: `
query loadPageWithContent (
$id: UUID!
) {
pageById(
id: $id
) {
...PageRead,
content
}
}
${pagePropsFragment}
`,
pageByPathWithContent: `
query loadPageWithContent (
$siteId: UUID!
$path: String!
) {
pageByPath(
siteId: $siteId
path: $path
) {
...PageRead,
content
}
}
${pagePropsFragment}
`
}
const gqlMutations = {
createPage: `
mutation createPage (
$alias: String
$allowComments: Boolean
$allowContributions: Boolean
$allowRatings: Boolean
$content: String!
$description: String!
$editor: String!
$icon: String
$isBrowsable: Boolean
$isSearchable: Boolean
$locale: String!
$path: String!
$publishState: PagePublishState!
$publishEndDate: Date
$publishStartDate: Date
$relations: [PageRelationInput!]
$scriptCss: String
$scriptJsLoad: String
$scriptJsUnload: String
$showSidebar: Boolean
$showTags: Boolean
$showToc: Boolean
$siteId: UUID!
$tags: [String!]
$title: String!
$tocDepth: PageTocDepthInput
) {
createPage (
alias: $alias
allowComments: $allowComments
allowContributions: $allowContributions
allowRatings: $allowRatings
content: $content
description: $description
editor: $editor
icon: $icon
isBrowsable: $isBrowsable
isSearchable: $isSearchable
locale: $locale
path: $path
publishState: $publishState
publishEndDate: $publishEndDate
publishStartDate: $publishStartDate
relations: $relations
scriptCss: $scriptCss
scriptJsLoad: $scriptJsLoad
scriptJsUnload: $scriptJsUnload
showSidebar: $showSidebar
showTags: $showTags
showToc: $showToc
siteId: $siteId
tags: $tags
title: $title
tocDepth: $tocDepth
) {
operation {
succeeded
message
}
page {
...PageRead
}
}
}
${pagePropsFragment}
`
}
/**
* The icon a page starts with.
*
* An Iconify reference, so that the icon picker opens on its search tab with this one selected rather
* than on the custom tab. Kept to a set seeded on every instance (`mdi`), so that it resolves without
* an administrator having added anything.
*/
export const DEFAULT_PAGE_ICON = 'mdi:file-document-outline'
export const usePageStore = defineStore('page', {
state: () => ({
@ -194,7 +27,7 @@ export const usePageStore = defineStore('page', {
createdAt: '',
description: '',
editor: '',
icon: 'las la-file-alt',
icon: DEFAULT_PAGE_ICON,
id: '',
isBrowsable: true,
isSearchable: true,
@ -227,18 +60,19 @@ export const usePageStore = defineStore('page', {
breadcrumbs: (state) => {
const siteStore = useSiteStore()
const pathPrefix = siteStore.useLocales ? `/${state.locale}` : ''
return transform(state.path.split('/'), (result, value, key) => {
return state.path.split('/').reduce((result, value, key) => {
result.push({
id: key,
title: value,
icon: 'las la-file-alt',
locale: 'en',
path: (last(result)?.path || pathPrefix) + `/${value}`
path: (result.at(-1)?.path || pathPrefix) + `/${value}`
})
return result
}, [])
},
folderPath: (state) => {
return initial(state.path.split('/')).join('/')
return state.path.split('/').slice(0, -1).join('/')
},
isHome: (state) => {
return ['', 'home'].includes(state.path)
@ -252,7 +86,7 @@ export const usePageStore = defineStore('page', {
const editorStore = useEditorStore()
const siteStore = useSiteStore()
try {
const pageData = await API_CLIENT.get(`sites/${siteStore.id}/pages/${id ?? fastHash(path)}`, {
const pageData = await API_CLIENT.get(`sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`, {
searchParams: {
withContent
}
@ -267,12 +101,17 @@ export const usePageStore = defineStore('page', {
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
// Update editor state timestamps
const curDate = DateTime.utc()
const curDate = Temporal.Now.instant()
editorStore.$patch({
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate
})
} catch (err) {
// -> A missing page is an ordinary outcome, not a failure: it is what puts a new instance in
// front of the welcome screen, and what offers to create the page anywhere else
if (err.response?.status === 404) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
console.warn(err)
throw err
}
@ -283,30 +122,15 @@ export const usePageStore = defineStore('page', {
async pageAlias (alias) {
const siteStore = useSiteStore()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query fetchPathFromAlias (
$siteId: UUID!
$alias: String!
) {
pathFromAlias (
siteId: $siteId
alias: $alias
) {
id
path
}
}
`,
variables: { siteId: siteStore.id, alias },
fetchPolicy: 'cache-first'
})
const pagePath = cloneDeep(resp?.data?.pathFromAlias)
const pagePath = await API_CLIENT.get(`sites/${siteStore.id}/pages/alias/${alias}`).json()
if (!pagePath?.id) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
return pagePath.path
} catch (err) {
if (err.response?.status === 404) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
console.warn(err)
throw err
}
@ -350,7 +174,7 @@ export const usePageStore = defineStore('page', {
// -> Default Page Path
let newPath = path
if (!path && path !== '') {
const parentPath = basePath || basePath === '' ? basePath : dropRight(this.path.split('/'), 1).join('/')
const parentPath = basePath || basePath === '' ? basePath : this.path.split('/').slice(0, -1).join('/')
newPath = parentPath ? `${parentPath}/new-page` : 'new-page'
}
@ -361,7 +185,7 @@ export const usePageStore = defineStore('page', {
path: newPath,
title: title ?? '',
description: description ?? '',
icon: 'las la-file-alt',
icon: DEFAULT_PAGE_ICON,
alias: '',
publishState: 'published',
relations: [],
@ -377,29 +201,12 @@ export const usePageStore = defineStore('page', {
* PAGE - DUPLICATE
*/
async pageDuplicate ({ sourecePageId, title, path }) {
const siteStore = useSiteStore()
try {
const resp = await APOLLO_CLIENT.query({
query: `
query loadPageSource (
$id: UUID!
) {
pageById(
id: $id
) {
id
content
contentType
description
editor
}
}
`,
variables: {
id: sourecePageId ?? pageStore.id
},
fetchPolicy: 'network-only'
})
const pageData = cloneDeep(resp?.data?.pageById ?? {})
const pageData = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${sourecePageId ?? this.id}`,
{ searchParams: { withContent: true } }
).json()
if (!pageData?.id) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
@ -449,73 +256,23 @@ export const usePageStore = defineStore('page', {
* PAGE - MOVE
*/
async pageMove ({ id, title, path } = {}) {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation movePage (
$id: UUID!
$destinationLocale: String!
$destinationPath: String!
$title: String
) {
movePage (
id: $id
destinationLocale: $destinationLocale
destinationPath: $destinationPath
title: $title
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id,
destinationLocale: this.locale,
destinationPath: path,
title
const siteStore = useSiteStore()
unwrap(await API_CLIENT.put(`sites/${siteStore.id}/pages/${id}/path`, {
json: {
path,
...(title ? { title } : {})
}
})
const result = resp?.data?.movePage?.operation ?? {}
if (!result.succeeded) {
throw new Error(result.message)
} else {
this.router.replace(`/${path}`)
}
}).json())
this.router.replace(`/${path}`)
},
/**
* PAGE - Rename
*/
async pageRename ({ id, title } = {}) {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation renamePage (
$id: UUID!
$patch: PageUpdateInput!
) {
updatePage (
id: $id
patch: $patch
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: id,
patch: {
title
}
}
})
const result = resp?.data?.updatePage?.operation ?? {}
if (!result.succeeded) {
throw new Error(result.message)
}
const siteStore = useSiteStore()
unwrap(await API_CLIENT.patch(`sites/${siteStore.id}/pages/${id}`, {
json: { title }
}).json())
// Update page store
if (id === this.id) {
@ -529,118 +286,77 @@ export const usePageStore = defineStore('page', {
const editorStore = useEditorStore()
const siteStore = useSiteStore()
try {
// -> The render goes up with the content: the markdown pipeline runs here, in the editor, and
// what the preview shows is what gets stored. The server post-processes it — sanitizing it
// against what this author may embed, and deriving the table of contents — so the page it
// returns is the authority on what was actually saved.
const body = {
...pick(this, [
'alias',
'allowComments',
'allowContributions',
'allowRatings',
'content',
'description',
'icon',
'isBrowsable',
'isSearchable',
'password',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'render',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
])
}
let pageData
if (editorStore.mode === 'create') {
const resp = await APOLLO_CLIENT.mutate({
mutation: gqlMutations.createPage,
variables: {
...pick(this, [
'alias',
'allowComments',
'allowContributions',
'allowRatings',
'content',
'description',
'icon',
'isBrowsable',
'isSearchable',
'locale',
'password',
'path',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
]),
editor: editorStore.editor,
siteId: siteStore.id
const resp = unwrap(await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: {
...body,
locale: this.locale,
path: this.path,
editor: editorStore.editor
}
})
const result = resp?.data?.createPage?.operation ?? {}
if (!result.succeeded) {
throw new Error(result.message)
}
const pageData = cloneDeep(resp.data.createPage.page ?? {})
}).json())
pageData = resp?.page
if (!pageData?.id) {
throw new Error('ERR_CREATED_PAGE_NOT_FOUND')
}
// Update page store
this.$patch({
...pageData,
relations: pageData.relations.map(r => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
} else {
const resp = unwrap(await API_CLIENT.patch(`sites/${siteStore.id}/pages/${this.id}`, {
json: body
}).json())
pageData = resp?.page
if (!pageData?.id) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
}
editorStore.$patch({
mode: 'edit'
})
// Update page store
this.$patch({
...pageData,
relations: (pageData.relations ?? []).map(r => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
if (editorStore.mode === 'create') {
editorStore.$patch({ mode: 'edit' })
this.router.replace(`/${this.path}`)
} else {
const resp = await APOLLO_CLIENT.mutate({
mutation: `
mutation savePage (
$id: UUID!
$patch: PageUpdateInput!
) {
updatePage (
id: $id
patch: $patch
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: this.id,
patch: {
...pick(this, [
'alias',
'allowComments',
'allowContributions',
'allowRatings',
'content',
'description',
'icon',
'isBrowsable',
'isSearchable',
'password',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
]),
reasonForChange: editorStore.reasonForChange
}
}
})
const result = resp?.data?.updatePage?.operation ?? {}
if (!result.succeeded) {
throw new Error(result.message)
}
}
// Update editor state timestamps
const curDate = DateTime.utc()
const curDate = Temporal.Now.instant()
editorStore.$patch({
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate,
@ -662,9 +378,38 @@ export const usePageStore = defineStore('page', {
}
})
/**
* Turn a refused request back into an error.
*
* The API client is set up not to throw on 400 (see `boot/api.js`), so a rejected save arrives as a
* parsed error envelope rather than an exception and reading it as a success is how a validation
* failure ends up reported as something unrelated.
*/
function unwrap (resp) {
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
return resp
}
/**
* Reduce a route path to the form the server stores a page under.
*
* A page is looked up by the hash of its path, so the two sides have to agree on what the path *is*
* before hashing it: the router hands over `/docs/intro`, the server holds `docs/intro`, and the site
* root is the `home` page rather than an empty path.
*/
function normalizePath (path) {
const clean = (path ?? '').replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase()
return clean || 'home'
}
/**
* Fast, non-cryptographic 53-bit hash to encode page paths.
* Returns a URL-safe hex string.
*
* Mirrored on the server as `generatePathHash` in `backend/helpers/common.ts` the two have to stay
* identical, since this is what a page is addressed by.
*/
function fastHash (str, seed = 0) {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { clone, sortBy } from 'lodash-es'
import { sortBy } from 'es-toolkit/array'
import { useUserStore } from './user'
@ -114,106 +114,80 @@ export const useSiteStore = defineStore('site', {
const siteInfo = await API_CLIENT.get(`sites/${hostname}`).json()
if (siteInfo) {
this.$patch({
id: clone(siteInfo.id),
hostname: clone(siteInfo.hostname),
title: clone(siteInfo.title),
description: clone(siteInfo.description),
logoText: clone(siteInfo.logoText),
company: clone(siteInfo.company),
contentLicense: clone(siteInfo.contentLicense),
footerExtra: clone(siteInfo.footerExtra),
id: siteInfo.id,
hostname: siteInfo.hostname,
title: siteInfo.title,
description: siteInfo.description,
logoText: siteInfo.logoText,
company: siteInfo.company,
contentLicense: siteInfo.contentLicense,
footerExtra: siteInfo.footerExtra,
features: {
...this.features,
...clone(siteInfo.features)
...siteInfo.features
},
editors: {
asciidoc: clone(siteInfo.editors.asciidoc.isActive),
markdown: clone(siteInfo.editors.markdown.isActive),
wysiwyg: clone(siteInfo.editors.wysiwyg.isActive)
asciidoc: siteInfo.editors.asciidoc.isActive,
markdown: siteInfo.editors.markdown.isActive,
wysiwyg: siteInfo.editors.wysiwyg.isActive
},
locales: {
primary: clone(siteInfo.locales.primary),
active: sortBy(clone(siteInfo.locales.active), ['nativeName', 'name'])
primary: siteInfo.locales.primary,
active: sortBy(siteInfo.locales.active, ['nativeName', 'name'])
},
tags: [],
tagsLoaded: false,
theme: {
...this.theme,
...clone(siteInfo.theme)
...siteInfo.theme
}
})
} else {
throw new Error('Invalid Site')
}
} catch (err) {
console.warn(err)
console.warn(err.networkError?.result ?? err.message)
console.warn(err.message)
throw err
}
},
async fetchTags (forceRefresh = false) {
if (this.tagsLoaded && !forceRefresh) { return }
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getSiteTags ($siteId: UUID!) {
tags (
siteId: $siteId
) {
tag
usageCount
}
}
`,
variables: {
siteId: this.id
}
})
const tags = await API_CLIENT.get(`sites/${this.id}/tags`).json()
this.$patch({
tags: resp.data.tags ?? [],
tags: tags ?? [],
tagsLoaded: true
})
} catch (err) {
console.warn(err.networkError?.result ?? err.message)
console.warn(err.message)
throw err
}
},
/**
* Load the sidebar menu a page resolves to.
*
* @param id The page's `navigationId`, which addresses either a tree entry that overrides the menu
* or the site itself for the one every page inherits
*/
async fetchNavigation (id) {
try {
const resp = await APOLLO_CLIENT.query({
query: `
query getNavigationItems ($id: UUID!) {
navigationById (
id: $id
) {
id
type
label
icon
target
openInNewWindow
children {
id
type
label
icon
target
openInNewWindow
}
}
}
`,
variables: { id }
})
const items = await API_CLIENT.get(`sites/${this.id}/navigation/${id}`).json()
this.$patch({
nav: {
currentId: id,
items: resp?.data?.navigationById ?? []
items: items ?? []
}
})
} catch (err) {
console.warn(err.networkError?.result ?? err.message)
throw err
// -> An empty sidebar is the right outcome for a menu nobody has set up, rather than an error
// in front of a reader who cannot act on it
console.warn(err.message)
this.$patch({
nav: {
currentId: id,
items: []
}
})
}
}
}

@ -21,6 +21,19 @@ export default defineConfig(({ mode }) => {
include: ['!/_blocks/**']
},
outDir: '../assets',
rollupOptions: {
// -> A second entry alongside the app: the markdown pipeline on its own, so the backend can
// drive it in a headless browser to re-render a page server-side
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
renderer: fileURLToPath(new URL('./src/renderers/headless.js', import.meta.url))
},
output: {
// -> The renderer keeps a fixed name because it is referenced from a static page served by
// the backend, which has no way to look up a hashed one
entryFileNames: chunk => chunk.name === 'renderer' ? '_assets/renderer.js' : '_assets/[name]-[hash].js'
}
},
target: 'es2022'
},
optimizeDeps: {

Loading…
Cancel
Save