mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1070 lines
37 KiB
1070 lines
37 KiB
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
|
|
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
|
|
import {
|
|
CustomError,
|
|
generatePathHash,
|
|
normalizePagePath,
|
|
timingSafeCompare
|
|
} from '../helpers/common.ts'
|
|
import type { RenderPermissions, TocNode } from './rendering.ts'
|
|
import type { DeletedEntry } from './tree.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',
|
|
redirect: 'redirect'
|
|
}
|
|
|
|
/**
|
|
* The editor whose pages send their reader somewhere else.
|
|
*
|
|
* A redirection is an ordinary page — it has a path, a title, an icon and a place in the tree, and is
|
|
* browsable like any other — with nothing to read: no body, no render, and therefore nothing for the
|
|
* search index to hold. What an author fills in is where it points, and that is what its content
|
|
* column carries. See `normalizeRedirectContent`.
|
|
*/
|
|
const REDIRECT_EDITOR = 'redirect'
|
|
|
|
/** 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
|
|
/**
|
|
* The page's password, if it has one. Present only for a requester who may edit the page — see
|
|
* `getPage`'s `withPassword`. Absent, rather than null, for everyone else: a reader cannot tell a
|
|
* page with no password from one whose password was withheld, and does not need to.
|
|
*/
|
|
password?: string | null
|
|
/** Whether the body was withheld because the page is password protected. See `getPage`. */
|
|
isLocked: boolean
|
|
relations: any[]
|
|
tags: string[]
|
|
toc: TocNode[]
|
|
render: string
|
|
/**
|
|
* The source. Present when the request asked for it, and always for a redirection — see `toPage`,
|
|
* and `RedirectContent` for what a redirection's holds.
|
|
*/
|
|
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
|
|
/**
|
|
* Why this save is being made, as the editor's reason-for-change prompt collected it. Not a page
|
|
* field: it belongs to the version this save produces, and is recorded on the history row.
|
|
*/
|
|
reasonForChange?: 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)
|
|
}
|
|
|
|
/**
|
|
* Normalize a path to the form that gets stored, and refuse it if what is left is not addressable.
|
|
*
|
|
* Casing and spaces are corrected rather than rejected — `My Page` is a path someone meant, and it
|
|
* means `my-page`. Anything else outside the allowed characters is not something to guess at.
|
|
*/
|
|
function normalizePath(input: string): string {
|
|
const path = normalizePagePath(input)
|
|
if (!rePagePath.test(path)) {
|
|
throw new CustomError(
|
|
'pageInvalidPath',
|
|
'A page path may only contain alphanumeric, hyphen, underscore and slash characters.'
|
|
)
|
|
}
|
|
return path
|
|
}
|
|
|
|
/**
|
|
* Where a redirection points, as its content column holds it.
|
|
*
|
|
* `kind` is stored rather than sniffed off the target, because it is the question the author actually
|
|
* answered: a page of this wiki, or somewhere else. The two are not reliably told apart afterwards —
|
|
* `/help` is a page here and a perfectly good relative URL elsewhere — and the editor has to open on
|
|
* the choice that was made rather than on a guess about it.
|
|
*/
|
|
export interface RedirectContent {
|
|
kind: 'page' | 'url'
|
|
/** A rooted path within this wiki, or an absolute `http(s)` URL. */
|
|
target: string
|
|
/** Whether the reader is told where they are going before being taken there. */
|
|
showInterstitial: boolean
|
|
}
|
|
|
|
/**
|
|
* Read a redirection's target back out of what the editor sent, and refuse anything that would not
|
|
* send a reader anywhere.
|
|
*
|
|
* Re-serialized rather than stored as it arrived, so that the column holds one canonical spelling: a
|
|
* save that changes nothing then reports no change, and the history rows say what they mean.
|
|
*
|
|
* A URL target is held to `http`/`https` deliberately. This value ends up in a `location` assignment,
|
|
* so any other scheme is either useless (`mailto:` in a redirect that nobody chose to follow) or an
|
|
* invitation (`javascript:`) — and a redirection is followed without the reader clicking anything.
|
|
*/
|
|
function normalizeRedirectContent(content: string | undefined): string {
|
|
let parsed: any
|
|
try {
|
|
parsed = JSON.parse(content ?? '')
|
|
} catch {
|
|
throw new CustomError('pageRedirectInvalid', 'A redirection needs a target.')
|
|
}
|
|
const kind = parsed?.kind === 'url' ? 'url' : 'page'
|
|
const target = typeof parsed?.target === 'string' ? parsed.target.trim() : ''
|
|
if (target.length < 1) {
|
|
throw new CustomError('pageRedirectMissingTarget', 'A redirection needs a target.')
|
|
}
|
|
if (kind === 'url') {
|
|
if (!/^https?:\/\/\S/i.test(target)) {
|
|
throw new CustomError(
|
|
'pageRedirectInvalidUrl',
|
|
'A redirection to a URL must be a complete http:// or https:// address.'
|
|
)
|
|
}
|
|
} else if (!target.startsWith('/') || target.startsWith('//')) {
|
|
throw new CustomError(
|
|
'pageRedirectInvalidPath',
|
|
'A redirection to a page of this wiki must be a path starting with a slash.'
|
|
)
|
|
}
|
|
const redirect: RedirectContent = {
|
|
kind,
|
|
target,
|
|
showInterstitial: parsed?.showInterstitial === true
|
|
}
|
|
return JSON.stringify(redirect)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param locked Withhold the body — the source, the rendered HTML, the table of contents drawn from
|
|
* it, and the relation links written onto the page. The metadata stays: a reader
|
|
* looking at the lock screen is told what page they are being asked for a password to.
|
|
* @param withPassword Include the page's own password. Only for a requester who may edit the page,
|
|
* which is the one that has to be able to read it back and save it again.
|
|
* @param withContent Include the source. A redirection's comes back either way: its content is not
|
|
* a body somebody wrote, it is where the page sends its reader — which every
|
|
* reader is about to be shown by being taken there. Withholding it would leave
|
|
* the page view unable to do the one thing the page is for, and the page view
|
|
* does not ask for content.
|
|
*/
|
|
private toPage(
|
|
row: any,
|
|
{
|
|
withContent = false,
|
|
withPassword = false,
|
|
locked = false
|
|
}: { withContent?: boolean; withPassword?: boolean; locked?: 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,
|
|
...(withPassword ? { password: row.password } : {}),
|
|
isLocked: locked,
|
|
relations: locked ? [] : (row.relations ?? []),
|
|
tags: row.tags ?? [],
|
|
toc: locked ? [] : (row.toc ?? []),
|
|
render: locked ? '' : (row.render ?? ''),
|
|
...((withContent || row.editor === REDIRECT_EDITOR) && !locked
|
|
? { 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.
|
|
*
|
|
* A password-protected page still comes back to a requester who has not unlocked it: the metadata
|
|
* is what the lock screen is drawn from. What the password withholds is the body — see `toPage`'s
|
|
* `locked`. Anything that puts a page's text in front of a reader has to go through here, or
|
|
* through the same check, because the enforcement is this method and not the client.
|
|
*
|
|
* **The defaults hand over the whole page**, `unlocked` and `withPassword` included, the way they do
|
|
* for `publicOnly` beside them: most callers here are a save, a move, a delete or a re-render, and
|
|
* none of those is a reader — a save that got a withheld body back would answer its author with an
|
|
* empty page, and a re-render would store one. A path that serves a reader has to say so, and there
|
|
* are exactly two: the `GET` route, and `unlockPage` below.
|
|
*
|
|
* @param unlocked Whether the password has been satisfied for this requester. Route-level concern:
|
|
* see `unlockedFor` in `api/pages.ts`. A function is called with the page's id once
|
|
* the row is in hand, which is what lets a caller answer per page even though it
|
|
* asked for the page by path hash.
|
|
* @param withPassword Whether to include the password value. For whoever may edit the page — not for
|
|
* a reader who just entered it, who needs it no more after that.
|
|
*/
|
|
async getPage({
|
|
siteId,
|
|
id,
|
|
hash,
|
|
locale,
|
|
withContent = false,
|
|
publicOnly = false,
|
|
unlocked = true,
|
|
withPassword = true
|
|
}: {
|
|
siteId: string
|
|
id?: string
|
|
hash?: string
|
|
locale?: string
|
|
withContent?: boolean
|
|
/** Restrict to what a reader with no session may see: published pages. */
|
|
publicOnly?: boolean
|
|
unlocked?: boolean | ((pageId: string) => boolean)
|
|
withPassword?: 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, and nothing else. A password does not hide a page from them —
|
|
// it withholds the body until they enter it, which is what `locked` below does.
|
|
conditions.push(eq(pagesTable.publishState, 'published'))
|
|
}
|
|
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
|
|
}
|
|
const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked
|
|
return this.toPage(
|
|
{
|
|
...row.page,
|
|
authorName: row.authorName,
|
|
navigationId: row.navigationId,
|
|
navigationMode: row.navigationMode
|
|
},
|
|
{ withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check a page's password, and hand the page over if it matches.
|
|
*
|
|
* Deliberately the only way past the lock: a reader gets the body from here or from a `getPage` the
|
|
* route has already marked as unlocked, and never from a flag the browser sent.
|
|
*
|
|
* @returns The page, its body included, or null when the password is wrong or the page has none —
|
|
* the caller cannot tell those apart, and neither can whoever is guessing.
|
|
*/
|
|
async unlockPage({
|
|
siteId,
|
|
id,
|
|
hash,
|
|
locale,
|
|
password,
|
|
publicOnly = false
|
|
}: {
|
|
siteId: string
|
|
id?: string
|
|
hash?: string
|
|
locale?: string
|
|
password: string
|
|
publicOnly?: boolean
|
|
}): Promise<Page | null> {
|
|
/*
|
|
Asked for as a reader would see it, for two reasons: a wrong guess must not assemble the body in
|
|
the first place, and `isLocked` is how this knows there is a password to check at all.
|
|
*/
|
|
const page = await this.getPage({
|
|
siteId,
|
|
id,
|
|
hash,
|
|
locale,
|
|
publicOnly,
|
|
unlocked: false,
|
|
withPassword: false
|
|
})
|
|
if (!page?.isLocked) {
|
|
return null
|
|
}
|
|
const stored = await WIKI.db
|
|
.select({ password: pagesTable.password })
|
|
.from(pagesTable)
|
|
.where(eq(pagesTable.id, page.id))
|
|
.limit(1)
|
|
const expected = stored[0]?.password
|
|
if (!expected || !timingSafeCompare(password, expected)) {
|
|
return null
|
|
}
|
|
// -> Unlocked, but still without the password itself: entering it is not the same as being able
|
|
// to change it, and the reader has no further use for the value
|
|
return this.getPage({
|
|
siteId,
|
|
id: page.id,
|
|
publicOnly,
|
|
unlocked: true,
|
|
withPassword: false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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.')
|
|
}
|
|
const editor = input.editor || 'markdown'
|
|
const isRedirect = editor === REDIRECT_EDITOR
|
|
// -> A redirection has no body to be empty: what it holds instead is where it points, and that has
|
|
// its own rules about being filled in
|
|
const content = isRedirect ? normalizeRedirectContent(input.content) : input.content
|
|
if (!isRedirect && (!content || content.trim().length < 1)) {
|
|
throw new CustomError('pageEmptyContent', 'A page cannot be empty.')
|
|
}
|
|
|
|
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 } = await WIKI.models.rendering.postProcess(
|
|
siteId,
|
|
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,
|
|
contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text',
|
|
description: input.description ?? '',
|
|
editor,
|
|
hash,
|
|
icon: input.icon ?? '',
|
|
isBrowsable: input.isBrowsable ?? true,
|
|
// -> A redirection has nothing to find: a result for it would be a result whose page is a
|
|
// doorway to the page the reader actually wanted, which is the one search should offer
|
|
isSearchable: isRedirect ? false : (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.pageHistory.record({
|
|
siteId,
|
|
pageId: page.id,
|
|
action: 'created',
|
|
authorId: actor.id,
|
|
reason: input.reasonForChange
|
|
})
|
|
|
|
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
|
|
// -> Which editor authored a page is not something a save may change, so the row is the authority
|
|
// on whether this is a redirection
|
|
const isRedirect = existing.editor === REDIRECT_EDITOR
|
|
|
|
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 = isRedirect ? normalizeRedirectContent(patch.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) {
|
|
// -> Never for a redirection; see the same call in `createPage`
|
|
values.isSearchable = isRedirect ? false : 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 } = await WIKI.models.rendering.postProcess(siteId, 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
|
|
|
|
// -> Worked out before the write, against the row as it stands: the editor sends every field on
|
|
// every save, so the patch alone would report a change to all of them
|
|
const changedFields = WIKI.models.pageHistory.changedFields(existing, values)
|
|
|
|
await WIKI.db.update(pagesTable).set(values).where(eq(pagesTable.id, id))
|
|
|
|
const updated = (await this.getPage({ siteId, id })) as Page
|
|
|
|
await WIKI.models.pageHistory.record({
|
|
siteId,
|
|
pageId: id,
|
|
action: 'updated',
|
|
authorId: actor.id,
|
|
changedFields,
|
|
reason: patch.reasonForChange
|
|
})
|
|
|
|
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
|
|
|
|
// -> Recorded as its own kind of change rather than an edit: a move is what breaks inbound links,
|
|
// and a history list has to be able to say so
|
|
await WIKI.models.pageHistory.record({
|
|
siteId,
|
|
pageId: id,
|
|
action: 'moved',
|
|
authorId: actor.id,
|
|
changedFields: [
|
|
...(newPath !== page.path ? ['path'] : []),
|
|
...(title !== undefined && title.trim() !== page.title ? ['title'] : [])
|
|
]
|
|
})
|
|
|
|
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
|
|
}
|
|
// -> Before the row goes, and this version is what recovering the page would be built from
|
|
await WIKI.models.pageHistory.record({
|
|
siteId,
|
|
pageId: id,
|
|
action: 'deleted',
|
|
authorId: actor.id
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Delete the pages left behind by a folder deletion, which removed their tree entries already.
|
|
*
|
|
* Not optional tidying: a page is served from its own row, found by the hash of its path, and the
|
|
* tree is only consulted for where it sits in the site. A page whose tree entry went with the
|
|
* folder is therefore still live at its URL while being invisible to everything that lists the
|
|
* wiki -- including the file manager somebody would have to use to delete it.
|
|
*
|
|
* Each one is recorded as deleted first, exactly as deleting a single page does. `pageHistory`
|
|
* carries no foreign key back to `pages` precisely so that it outlives the row, which is what makes
|
|
* a folder deleted by mistake recoverable.
|
|
*/
|
|
async deleteOrphaned(siteId: string, entries: DeletedEntry[], actor: PageActor): Promise<void> {
|
|
if (entries.length < 1) {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
await WIKI.models.pageHistory.record({
|
|
siteId,
|
|
pageId: entry.id,
|
|
action: 'deleted',
|
|
authorId: actor.id
|
|
})
|
|
}
|
|
await WIKI.db.delete(pagesTable).where(
|
|
inArray(
|
|
pagesTable.id,
|
|
entries.map((entry) => entry.id)
|
|
)
|
|
)
|
|
|
|
// -> One per page, as deleting them one at a time would have sent: a subscriber mirroring the
|
|
// wiki has to hear about each page, not about the folder it happened to sit in
|
|
for (const entry of entries) {
|
|
await WIKI.models.hooks.emit('page:delete', {
|
|
id: entry.id,
|
|
path: entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName,
|
|
locale: entry.locale,
|
|
siteId,
|
|
authorId: actor.id
|
|
})
|
|
}
|
|
WIKI.logger.debug(`Deleted ${entries.length} page(s) that went with a deleted folder.`)
|
|
}
|
|
|
|
/**
|
|
* Ask for a page to be rendered 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; because that costs a browser it is queued rather than done here, one page at a time
|
|
* across the whole instance. See `models/rendering.ts`.
|
|
*
|
|
* What the render may carry is settled here, while there is still an actor to ask, and travels with
|
|
* the queued request.
|
|
*
|
|
* @returns False when there is no such page
|
|
* @throws `renderUnsupportedEditor` for a page the server cannot render, or
|
|
* `renderPuppeteerMissing` when nothing here could drain the queue
|
|
*/
|
|
async queueRerender(siteId: string, id: string, actor: PageActor): Promise<boolean> {
|
|
const page = await this.getPage({ siteId, id })
|
|
if (!page) {
|
|
return false
|
|
}
|
|
await WIKI.models.rendering.ensureCanRender(page.editor)
|
|
|
|
await WIKI.models.rendering.queuePage({
|
|
siteId,
|
|
pageId: page.id,
|
|
permissions: {
|
|
scripts: hasPermission(actor, 'write:scripts'),
|
|
styles: hasPermission(actor, 'write:styles')
|
|
},
|
|
requestedById: actor.id
|
|
})
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Store HTML the renderer produced for a page, and re-index it.
|
|
*
|
|
* The counterpart to `queueRerender`: the drain calls this once the browser has been through the
|
|
* content. Post-processed like any other render — it came from a browser either way — against the
|
|
* permissions the person who asked for it had.
|
|
*/
|
|
async storeRender(
|
|
siteId: string,
|
|
id: string,
|
|
html: string,
|
|
permissions: RenderPermissions
|
|
): Promise<void> {
|
|
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions)
|
|
|
|
const updated = await WIKI.db
|
|
.update(pagesTable)
|
|
.set({ render, toc, searchContent: text, updatedAt: sql`now()` })
|
|
.where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId)))
|
|
.returning({ locale: pagesTable.locale })
|
|
|
|
// -> Nothing was updated when the page went while it sat in the queue
|
|
if (updated[0]) {
|
|
await WIKI.models.search.indexPage(id, updated[0].locale)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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()
|