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.
wiki/backend/api/tree.ts

663 lines
23 KiB

import type { FastifyInstance, FastifyRequest } from 'fastify'
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts'
import { decodeTreePath } from '../helpers/common.ts'
import { actorFrom } from './pages.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.
*/
/**
* The entries of a tree listing this caller may see, and the folders leading to them.
*
* Filtered here rather than in the query for the same reason as everywhere else: a page rule can be a
* regular expression or a set of tags, so which rule decides an entry is only knowable per entry.
*
* A folder is judged on its own path, so a DENY over a branch hides the branch itself rather than
* leaving an empty folder to walk into. The consequence worth knowing is the other way round: a
* folder stays listed when the rules deny everything inside it but say nothing about the folder, and
* a reader opening it finds it empty. Hiding those would mean resolving every descendant of every
* folder on every listing, which is not worth what it costs.
*/
function visibleTreeItems<T extends { type?: string; folderPath?: string; fileName?: string }>(
req: FastifyRequest,
items: T[]
): T[] {
const actor = WIKI.models.groups.actorForRequest(req)
return items.filter((item) => {
const path = item.folderPath ? `${item.folderPath}/${item.fileName}` : (item.fileName ?? '')
const permission = item.type === 'asset' ? 'read:assets' : 'read:pages'
return WIKI.models.groups.checkAccess(actor, permission, {
path,
tags: (item as any).tags ?? []
})
})
}
/** A folder's own slash-separated path, which is what a rule over that branch addresses. */
function folderPathOf(folder: { folderPath?: string | null; fileName: string }): string {
const parent = decodeTreePath(folder.folderPath ?? '') ?? ''
return parent ? `${parent}/${folder.fileName}` : folder.fileName
}
/**
* Whether the caller holds a page permission over a folder, judged on the folder's own path.
*
* A folder is not a page and has no permissions of its own, so what governs it is what governs the
* branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as the
* pages in it, and only somebody who may reorganise pages there may rename or remove it.
*/
function mayOnFolder(req: FastifyRequest, permission: string, path: string): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
path
})
}
async function routes(app: FastifyInstance) {
/**
* BROWSE THE TREE
*/
app.get<{ Params: { siteId: string }; Querystring: TreeQuery }>(
'/sites/:siteId/tree',
{
/*
No route-level `permissions`: page permissions come from a group's RULES, and every entry is
filtered against them below — a caller allowed nowhere gets an empty listing rather than a
refusal, which is the same thing the tree would look like if the pages were not there.
*/
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
const items = await 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
})
return visibleTreeItems(req, items)
}
)
/**
* BROWSE THE TREE AS A READER
*/
app.get<{ Params: { siteId: string }; Querystring: { path?: string; locale?: string } }>(
'/sites/:siteId/tree/browse',
{
schema: {
summary: 'Browse the tree as a reader',
description:
"Lists one folder for the sidebar's browse menu: the pages a reader may open and the folders holding some, with assets, hidden pages and dead-end folders left out.\n\nA page and a folder can share a path — `/foo/bar` alongside the folder of pages under it — and such a pair comes back as a single entry with both `isPage` and `isFolder` set, since a reader sees one name with two ways in.\n\nReadable without a session, because a wiki is browsed by people who are not logged in — an anonymous request sees only published pages with no password on them, which is exactly what the page view itself would serve them. Requires the site's `browse` feature to be on.",
tags: ['Tree'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
path: {
type: 'string',
maxLength: 2048,
description: 'Slash-separated path of the folder to list. The site root when absent.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
}
}
},
response: {
200: {
description: 'One level of the tree',
type: 'object',
properties: {
path: {
type: 'string',
description: 'The folder that was listed. Empty at the site root.'
},
title: {
type: 'string',
description: "The folder's title. Empty at the site root, which is not a folder."
},
truncated: {
type: 'boolean',
description: 'Whether the folder holds more entries than were returned.'
},
items: {
type: 'array',
items: { $ref: 'BrowseItem#' }
}
}
}
}
}
},
async (req, reply) => {
const site = WIKI.sites[req.params.siteId]
if (!site) {
return reply.notFound('This site does not exist.')
}
// -> The same setting that hides the sidebar's Browse button, enforced where it counts: with
// browsing off, the tree is not something to hand out one folder at a time either
if (!site.config?.features?.browse) {
return reply.forbidden('Browsing is disabled on this site.')
}
const level = await WIKI.models.tree.browse({
siteId: req.params.siteId,
path: req.query.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId),
publicOnly: !req.session?.authenticated
})
if (!level) {
return reply.notFound('This folder does not exist.')
}
/*
A browse row carries a whole path rather than a folder/name pair, and stands for a page, a
folder, or both at once. Judged on that path either way: for the page it IS the page, and for
a folder it is the branch, which is what a rule over the branch is talking about.
*/
const actor = WIKI.models.groups.actorForRequest(req)
return {
...level,
items: level.items.filter((item) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { path: item.path })
)
}
}
)
/**
* LIST PAGES AS A READER
*/
app.get<{
Params: { siteId: string }
Querystring: {
path?: string
locale?: string
tags?: string
limit?: number
orderBy?: TreeOrderBy
orderByDirection?: 'asc' | 'desc'
depth?: number
}
}>(
'/sites/:siteId/tree/pages',
{
schema: {
summary: 'List pages as a reader',
description:
"Lists the pages under a path, ordered and limited, for an index block drawn inside a page. Folders are not part of the answer — this is a list of pages, at `depth` folders below the path when asked for.\n\nReadable without a session, because the page holding the block is: an anonymous request sees only published pages, the same set the page view would serve it. Unlike `/tree/browse` it is not gated on the site's `browse` feature, which governs the sidebar's browse menu rather than what a page may render.",
tags: ['Tree'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
path: {
type: 'string',
maxLength: 2048,
description: 'Slash-separated path to list. The site root when absent.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
},
tags: {
type: 'string',
description: 'Comma-separated list of tags a page must carry all of.'
},
limit: {
type: 'integer',
minimum: 1,
maximum: 1000,
default: 10
},
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 folders below the path to include. 0 is the path itself.'
}
}
},
response: {
200: {
description: 'The pages found',
type: 'array',
items: { $ref: 'ListedPage#' }
}
}
}
},
async (req, reply) => {
if (!WIKI.sites[req.params.siteId]) {
return reply.notFound('This site does not exist.')
}
const pages = await WIKI.models.tree.listPages({
siteId: req.params.siteId,
path: req.query.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId),
tags: splitList(req.query.tags),
limit: req.query.limit,
orderBy: req.query.orderBy,
orderByDirection: req.query.orderByDirection,
depth: req.query.depth,
publicOnly: !req.session?.authenticated
})
// -> An index block is drawn inside a page, but it lists other pages: each one still has to be
// the reader's to see
const actor = WIKI.models.groups.actorForRequest(req)
return pages.filter((page) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', {
path: page.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId)
})
)
}
)
/**
* GET FOLDER
*/
app.get<{ Params: { siteId: string; folderId: string } }>(
'/sites/:siteId/tree/folders/:folderId',
{
// -> Checked against the folder's own path below, not against the group-wide list
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.')
}
const folderPath = folderPathOf(folder)
// -> Not visible is the same as not there, so it answers as the id had matched nothing
if (!mayOnFolder(req, 'read:pages', folderPath)) {
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',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
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#' },
{ type: 'object', 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, reply) => {
/*
Against where the folder is going. `parentPath` is the slash-separated path when given; with
`parentId` the parent has to be looked up, and a missing one is left to the model to report.
*/
let parentPath = req.body.parentPath ?? ''
if (req.body.parentId) {
const parent = await WIKI.models.tree.getFolderById(req.body.parentId)
parentPath = parent ? folderPathOf(parent) : parentPath
}
const target = [parentPath, req.body.pathName].filter(Boolean).join('/')
if (!mayOnFolder(req, 'manage:pages', target)) {
return reply.forbidden('You are not allowed to create a folder here.')
}
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',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
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#' }, { type: 'object', 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.')
}
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) {
return reply.forbidden('You are not allowed to rename this folder.')
}
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',
{
/*
No route-level `permissions`: that hook reads the group-wide list, and page permissions come
from a group's RULES. Checked against the folder's own path below.
*/
schema: {
summary: 'Delete a folder',
description:
'Everything under the folder goes with it, pages and assets included. Each deleted page is recorded in its history first, so the branch can be recovered from there.',
tags: ['Tree'],
params: folderIdParam,
response: {
204: {
description: 'Folder deleted successfully'
}
}
}
},
async (req, reply) => {
// -> As deleting a single page does: every page going with the folder is recorded against
// whoever deleted it, so there has to be somebody to record
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Deleting a folder requires a logged in user.')
}
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.')
}
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) {
return reply.forbidden('You are not allowed to delete this folder.')
}
const removed = await WIKI.models.tree.deleteFolder(req.params.folderId)
// -> The tree entries are gone; these are the rows behind them, which is where a page and an
// asset actually live
await WIKI.models.pages.deleteOrphaned(req.params.siteId, removed.pages, actor)
await WIKI.models.assets.deleteOrphaned(req.params.siteId, removed.assets)
return reply.code(204).send()
}
)
}
export default routes