diff --git a/backend/api/assets.ts b/backend/api/assets.ts new file mode 100644 index 000000000..8235b7eff --- /dev/null +++ b/backend/api/assets.ts @@ -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 diff --git a/backend/api/index.ts b/backend/api/index.ts index 6221918f6..25986721d 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -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' }) } diff --git a/backend/api/navigation.ts b/backend/api/navigation.ts new file mode 100644 index 000000000..4f96a7ea9 --- /dev/null +++ b/backend/api/navigation.ts @@ -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 diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 2cbe9fe46..d03a7c211 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -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 ``, 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 ``, 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 }>( + '/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')) } ) } diff --git a/backend/api/schemas/asset.ts b/backend/api/schemas/asset.ts new file mode 100644 index 000000000..ec664cdf8 --- /dev/null +++ b/backend/api/schemas/asset.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * 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/.webp` will serve one.' + }, + createdAt: { + type: 'string', + format: 'date-time' + }, + updatedAt: { + type: 'string', + format: 'date-time' + } + } + }) +} diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts new file mode 100644 index 000000000..4e8fb151c --- /dev/null +++ b/backend/api/schemas/page.ts @@ -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 { + /** + * 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' } + } + }) +} diff --git a/backend/api/schemas/tree.ts b/backend/api/schemas/tree.ts new file mode 100644 index 000000000..adb0af18b --- /dev/null +++ b/backend/api/schemas/tree.ts @@ -0,0 +1,139 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * 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' + } + } + }) +} diff --git a/backend/api/tags.ts b/backend/api/tags.ts new file mode 100644 index 000000000..a9185c778 --- /dev/null +++ b/backend/api/tags.ts @@ -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 diff --git a/backend/api/tree.ts b/backend/api/tree.ts new file mode 100644 index 000000000..2ef6e4179 --- /dev/null +++ b/backend/api/tree.ts @@ -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 diff --git a/backend/controllers/render.ts b/backend/controllers/render.ts new file mode 100644 index 000000000..9964d252e --- /dev/null +++ b/backend/controllers/render.ts @@ -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 = ` + + + +Wiki.js Renderer + + + + + +` + +/** + * _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 diff --git a/backend/controllers/thumb.ts b/backend/controllers/thumb.ts new file mode 100644 index 000000000..379a3d29b --- /dev/null +++ b/backend/controllers/thumb.ts @@ -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 diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 75d8d3a45..1319b9479 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -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' } diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index bd509f96a..f45b5e3a0 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -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 * diff --git a/backend/helpers/images.ts b/backend/helpers/images.ts index 01f84c050..4fdd259e1 100644 --- a/backend/helpers/images.ts +++ b/backend/helpers/images.ts @@ -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 { + 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 + } +} diff --git a/backend/index.ts b/backend/index.ts index cc3fbe352..b9415147d 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -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' }) // ---------------------------------------- diff --git a/backend/locales/en.json b/backend/locales/en.json index 200082da8..a53a0964b 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/assets.ts b/backend/models/assets.ts new file mode 100644 index 000000000..4eebb9ece --- /dev/null +++ b/backend/models/assets.ts @@ -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 { + 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 { + 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`${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 { + 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 { + 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 { + 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 { + if (ids.length < 1) { + return + } + await WIKI.db.delete(assetsTable).where(inArray(assetsTable.id, ids)) + } +} + +export const assets = new Assets() diff --git a/backend/models/hooks.ts b/backend/models/hooks.ts index 8e84d4f3b..e04359c7b 100644 --- a/backend/models/hooks.ts +++ b/backend/models/hooks.ts @@ -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 { diff --git a/backend/models/index.ts b/backend/models/index.ts index 547c71753..d7d545911 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -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 } diff --git a/backend/models/navigation.ts b/backend/models/navigation.ts new file mode 100644 index 000000000..8ffe72f02 --- /dev/null +++ b/backend/models/navigation.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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() diff --git a/backend/models/pages.ts b/backend/models/pages.ts new file mode 100644 index 000000000..06a87d6b2 --- /dev/null +++ b/backend/models/pages.ts @@ -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 = { + 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 { + 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 { + 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, + actor: PageActor + ): Promise { + 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 = { 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) + } + if ( + patch.scriptJsLoad !== undefined || + patch.scriptJsUnload !== undefined || + patch.scriptCss !== undefined + ) { + values.scripts = this.buildScripts(patch, actor, existing.scripts as Record) + } + + // -> 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 { + 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 { + 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 { + 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 { + 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, + siteId: string, + existing: Record = {} + ): Record { + 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, + actor: PageActor, + existing: Record = {} + ): Record { + 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 { + 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() diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts new file mode 100644 index 000000000..18d7a3255 --- /dev/null +++ b/backend/models/rendering.ts @@ -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 ` diff --git a/frontend/src/components/UploadPendingAssetsDialog.vue b/frontend/src/components/UploadPendingAssetsDialog.vue index 226cee62a..287766bc7 100644 --- a/frontend/src/components/UploadPendingAssetsDialog.vue +++ b/frontend/src/components/UploadPendingAssetsDialog.vue @@ -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() } diff --git a/frontend/src/components/WelcomeOverlay.vue b/frontend/src/components/WelcomeOverlay.vue index bb7dcac93..f154cc93c 100644 --- a/frontend/src/components/WelcomeOverlay.vue +++ b/frontend/src/components/WelcomeOverlay.vue @@ -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() } diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index ecb97a5bc..a73c72818 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -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 () { diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue index 121c76a6a..bfec1f8a7 100644 --- a/frontend/src/pages/Search.vue +++ b/frontend/src/pages/Search.vue @@ -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(() => { diff --git a/frontend/src/renderers/headless.js b/frontend/src/renderers/headless.js new file mode 100644 index 000000000..063f056a8 --- /dev/null +++ b/frontend/src/renderers/headless.js @@ -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 diff --git a/frontend/src/stores/editor.js b/frontend/src/stores/editor.js index 7b3caaa41..b39eec74d 100644 --- a/frontend/src/stores/editor.js +++ b/frontend/src/stores/editor.js @@ -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 }) diff --git a/frontend/src/stores/page.js b/frontend/src/stores/page.js index cc6a78110..4273eb3fa 100644 --- a/frontend/src/stores/page.js +++ b/frontend/src/stores/page.js @@ -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 diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js index 568a703d1..2190aa3ac 100644 --- a/frontend/src/stores/site.js +++ b/frontend/src/stores/site.js @@ -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: [] + } + }) } } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2ea5f83cb..cec953525 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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: {