import { validate as uuidValidate } from 'uuid' import { audit } from '../helpers/audit.ts' import { CustomError, normalizePastedDestination } from '../helpers/common.ts' import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts' import { siteAssetKinds } from '../models/sites.ts' import type { SiteAssetKind } from '../models/sites.ts' import type { FastifyInstance } from 'fastify' /** How large one of a site's own images may be uploaded, before it is re-encoded. */ const imageUploadLimit = 10 * 1024 * 1024 /** * Site properties stored in the `config` JSONB column rather than as their own table column. * Anything listed here is merged into the existing config on update. */ const SITE_CONFIG_KEYS = [ 'title', 'description', 'company', 'contentLicense', 'footerExtra', 'banner', 'pageExtensions', 'logoText', 'sitemap', 'discoverable', 'auth', 'authStrategies', 'defaults', 'editors', 'features', 'locales', 'robots', 'uploads' ] as const /** * Sites API Routes */ async function routes(app: FastifyInstance) { // -> An image upload is the raw file rather than a multipart form: one file, no fields, and no // dependency to add. Registered inside this plugin, so every other route keeps rejecting an // image body outright. app.addContentTypeParser( [...imageMimeTypes, svgMimeType], { parseAs: 'buffer', bodyLimit: imageUploadLimit }, (req, body, done) => { done(null, body) } ) app.get( '/', { config: { /* `manage:sites` as well as `access:admin`, because managing sites starts with seeing which ones there are — the admin area's site selector is filled from here, and every site-bound screen hangs off it. `read:sites` used to stand where `manage:sites` does and was never a permission anybody could hold: nothing validates a name, and one that is not on the list the group editor offers simply never matches. */ permissions: ['manage:sites', 'access:admin'] }, schema: { summary: 'List all sites', tags: ['Sites'], response: { 200: { description: 'List of all sites', type: 'array', items: { $ref: 'Site#' } } } } }, async () => { const sites = await WIKI.models.sites.getAllSites() return sites.map((s: any) => ({ ...s.config, id: s.id, hostname: s.hostname, isEnabled: s.isEnabled, // -> See the note in `api/bootstrap.ts`: the stored block is not the one that is served comments: WIKI.models.comments.publicConfigFor(s.id) })) } ) app.get<{ Params: { siteIdorHostname: string }; Querystring: { strict?: boolean } }>( '/:siteIdorHostname', { config: { publicAccess: true }, schema: { summary: 'Get site info', tags: ['Sites'], params: { type: 'object', properties: { siteIdorHostname: { type: 'string', description: 'Either a site ID, hostname or "current" to use the request hostname.', anyOf: [{ format: 'uuid' }, { enum: ['current'] }, { pattern: '^[a-z0-9.-]+$' }] } }, required: ['siteIdorHostname'] }, querystring: { type: 'object', properties: { strict: { type: 'boolean', description: 'Whether to only return a site that exactly matches the hostname. Wildcard sites will not be matched.', default: false } } }, response: { 200: { description: 'Site info', type: 'object', $ref: 'Site#' } } } }, async (req, reply) => { let site: any if (req.params.siteIdorHostname === 'current' && req.hostname) { site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname, // FIXME: see the note below — `req.querystring` is not a Fastify property. strict: (req as any).querystring?.strict ?? false }) } else if (uuidValidate(req.params.siteIdorHostname)) { site = await WIKI.models.sites.getSiteById({ id: req.params.siteIdorHostname }) } else { site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteIdorHostname, // FIXME: pre-existing bug — Fastify exposes the parsed query string as `req.query`, not // `req.querystring`, so `strict` is always undefined here and the lookup is never strict. // Preserved as-is to keep the migration behavior-neutral; the fix is `req.query.strict`. strict: (req as any).querystring?.strict ?? false }) } if (site) { return { ...site.config, id: site.id, hostname: site.hostname, isEnabled: site.isEnabled, comments: WIKI.models.comments.publicConfigFor(site.id) } } else { return reply.notFound('Site does not exist.') } } ) /** * CREATE SITE */ app.post<{ Body: { hostname: string; title: string } }>( '/', { config: { // -> `create:sites` stood beside this one and matched nobody; see the note on the listing above permissions: ['manage:sites'] }, schema: { summary: 'Create a new site', tags: ['Sites'], body: { type: 'object', required: ['hostname', 'title'], properties: { hostname: { type: 'string', minLength: 1, maxLength: 255, pattern: '^(\\*|[a-z0-9.-]+)$' }, title: { type: 'string', minLength: 1, maxLength: 255 } }, examples: [ { hostname: 'wiki.example.org', title: 'My Wiki Site' } ] }, response: { 200: { description: 'Site created successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, id: { type: 'string', format: 'uuid' } } } } } }, async (req, reply) => { // -> Validate inputs if ( !req.body.hostname || req.body.hostname.length < 1 || !/^(\\*)|([a-z0-9\-.:]+)$/.test(req.body.hostname) ) { throw new CustomError('siteCreateInvalidHostname', 'Invalid Site Hostname') } if (!req.body.title || req.body.title.length < 1 || !/^[^<>"]+$/.test(req.body.title)) { throw new CustomError('siteCreateInvalidTitle', 'Invalid Site Title') } // -> Check for duplicate hostname if (!(await WIKI.models.sites.isHostnameUnique(req.body.hostname))) { if (req.body.hostname === '*') { throw new CustomError( 'siteCreateDuplicateCatchAll', 'A site with a catch-all hostname already exists! Cannot have 2 catch-all hostnames.' ) } else { throw new CustomError( 'siteCreateDuplicateHostname', 'A site with a this hostname already exists! Cannot have duplicate hostnames.' ) } } // -> Create site try { const result = await WIKI.models.sites.createSite(req.body.hostname, { title: req.body.title }) await audit(req, 'admin', 'createSite', { siteId: result.id, hostname: req.body.hostname, title: req.body.title }) return { ok: true, message: 'Site created successfully.', id: result.id } } catch (err: any) { WIKI.logger.warn(err) return reply.internalServerError() } } ) /** * UPDATE SITE */ app.put<{ Params: { siteId: string } Body: { isEnabled?: boolean hostname?: string title?: string description?: string company?: string contentLicense?: string footerExtra?: string banner?: { isEnabled?: boolean; title?: string; content?: string } pageExtensions?: string[] logoText?: boolean sitemap?: boolean discoverable?: boolean auth?: Record authStrategies?: Array<{ id: string; order?: number; isVisible?: boolean }> defaults?: Record editors?: Record }> features?: Record locales?: { primary?: string active?: string[] forcePrefix?: boolean showMenu?: boolean } robots?: Record uploads?: Record } }>( '/:siteId', { config: { permissions: ['manage:sites'] }, schema: { summary: 'Update a site', description: "Every site setting except its theme and its storage, which have routes and permissions of their own — see `PUT /sites/{siteId}/theme` (`manage:theme`) and `PUT /sites/{siteId}/storage` (`manage:storage`). The three do not overlap, so changing all of a site's settings takes all three.", tags: ['Sites'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, body: { type: 'object', properties: { isEnabled: { type: 'boolean' }, hostname: { type: 'string', minLength: 1, maxLength: 255, pattern: '^(\\*|[a-z0-9.-]+)$' }, title: { type: 'string', minLength: 1, maxLength: 255 }, description: { type: 'string' }, company: { type: 'string' }, contentLicense: { type: 'string' }, footerExtra: { type: 'string' }, banner: { $ref: 'Site#/properties/banner' }, pageExtensions: { type: 'array', items: { type: 'string', pattern: '^[a-z0-9]+$' } }, logoText: { type: 'boolean' }, sitemap: { type: 'boolean' }, discoverable: { type: 'boolean' }, auth: { $ref: 'Site#/properties/auth' }, authStrategies: { $ref: 'Site#/properties/authStrategies' }, defaults: { $ref: 'Site#/properties/defaults' }, editors: { $ref: 'Site#/properties/editors' }, features: { $ref: 'Site#/properties/features' }, locales: { $ref: 'Site#/properties/locales' }, robots: { $ref: 'Site#/properties/robots' }, uploads: { $ref: 'Site#/properties/uploads' } }, examples: [ { hostname: 'wiki.example.org', title: 'My Wiki Site' } ] }, response: { 200: { description: 'Site updated successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { // -> Validate inputs if (req.body.title !== undefined && !/^[^<>"]+$/.test(req.body.title)) { throw new CustomError('siteUpdateInvalidTitle', 'Invalid Site Title') } const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) if (!site) { return reply.notFound('Site does not exist.') } // -> Check for duplicate hostname if ( req.body.hostname !== undefined && req.body.hostname !== site.hostname && !(await WIKI.models.sites.isHostnameUnique(req.body.hostname)) ) { if (req.body.hostname === '*') { throw new CustomError( 'siteUpdateDuplicateCatchAll', 'A site with a catch-all hostname already exists! Cannot have 2 catch-all hostnames.' ) } else { throw new CustomError( 'siteUpdateDuplicateHostname', 'A site with a this hostname already exists! Cannot have duplicate hostnames.' ) } } // -> Validate locales against the installed ones, and against what the site ends up with once // the patch is merged, so that a partial update cannot leave the primary locale inactive if (req.body.locales) { const installedCodes = (await WIKI.models.locales.getInstalledLocales()).map( (lc: any) => lc.code ) const active = req.body.locales.active ?? site.config.locales?.active ?? [] const primary = req.body.locales.primary ?? site.config.locales?.primary if (active.length < 1) { throw new CustomError( 'siteUpdateNoActiveLocale', 'At least one active locale is required.' ) } const unknownCodes = [...active, primary].filter( (code) => code && !installedCodes.includes(code) ) if (unknownCodes.length > 0) { throw new CustomError( 'siteUpdateUnknownLocale', `Locale is not installed: ${[...new Set(unknownCodes)].join(', ')}` ) } if (!active.includes(primary)) { throw new CustomError( 'siteUpdatePrimaryLocaleNotActive', 'The primary locale must be one of the active locales.' ) } } // -> Split the patch between real columns and the config JSONB blob const config: Record = {} for (const key of SITE_CONFIG_KEYS) { if (req.body[key] !== undefined) { config[key] = req.body[key] } } /* The pasted-uploads destination is stored in one form, so that what the admin area reads back is what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and the editor should not have to know that. */ if (config.uploads?.pastedDestination !== undefined) { config.uploads.pastedDestination = normalizePastedDestination( config.uploads.pastedDestination ) } // -> Update site try { await WIKI.models.sites.updateSite(req.params.siteId, { hostname: req.body.hostname, isEnabled: req.body.isEnabled, ...(Object.keys(config).length < 1 ? {} : { config }) }) // -> The config is a large nested blob covering everything from the theme to the storage // layout, so its top-level sections are what is recorded rather than the whole of it await audit(req, 'admin', 'updateSite', { siteId: req.params.siteId, ...(req.body.hostname !== undefined ? { hostname: req.body.hostname } : {}), ...(req.body.isEnabled !== undefined ? { isEnabled: req.body.isEnabled } : {}), changedFields: [ ...(req.body.hostname !== undefined ? ['hostname'] : []), ...(req.body.isEnabled !== undefined ? ['isEnabled'] : []), ...Object.keys(config).map((section) => `config.${section}`) ] }) return { ok: true, message: 'Site updated successfully.' } } catch (err: any) { WIKI.logger.warn(err) return reply.internalServerError() } } ) /** * UPDATE SITE THEME * * Its own route rather than a section of `PUT /:siteId`, because it is its own permission: * `manage:theme` is how a wiki hands somebody the look of a site without handing them its * hostname, its locales, its authentication or its page defaults. Folding the theme into the * general update would mean either refusing a theme manager outright — which is what happened * before this existed, since the admin area offers them the screen — or granting them every * other setting in the same body. * * `manage:sites` is NOT accepted here, and that is the point rather than an oversight: a site's * settings are split across three permissions that do not overlap (`manage:sites`, `manage:theme`, * `manage:storage`), so somebody who is to change all of them holds all three. * * There is no matching `GET`: a site's theme is public, served with the site itself to every * reader that has to draw it, so `GET /sites/{siteIdorHostname}` already answers with it and a * second copy behind a permission would say the same thing less usefully. */ app.put<{ Params: { siteId: string }; Body: Record }>( '/:siteId/theme', { config: { // -> `manage:theme` alone. The three permissions covering a site's settings are deliberately // disjoint, so holding `manage:sites` says nothing about the look of the site permissions: ['manage:theme'] }, schema: { summary: "Update a site's theme", description: "The site's appearance: its colors, fonts, layout choices and the raw CSS, head and body it injects into every page. Merged onto what is stored, so a partial body leaves the rest alone.\n\nThe three `inject*` fields are served into the document as written — that is what they are for — so this route is a trust boundary, and `manage:theme` is a permission to hand out on that understanding.", tags: ['Sites'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, body: { $ref: 'Site#/properties/theme' }, response: { 200: { description: 'Site theme updated successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) if (!site) { return reply.notFound('Site does not exist.') } await WIKI.models.sites.updateSite(req.params.siteId, { config: { theme: req.body } }) // -> Which fields were set, never their values: `injectCSS` and friends are whole stylesheets // and scripts, and the audit log records what was touched rather than copying content into it await audit(req, 'admin', 'updateSiteTheme', { siteId: req.params.siteId, changedFields: Object.keys(req.body) }) return { ok: true, message: 'Site theme updated successfully.' } } ) /** * UPLOAD SITE IMAGE */ app.put<{ Params: { siteId: string; kind: SiteAssetKind } }>( '/:siteId/images/:kind', { config: { permissions: ['manage:sites'] }, schema: { summary: "Replace one of a site's images", description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${imageUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type.\n\nA raster upload is re-encoded to the size and format the image is served at — 512x512 WebP for a logo, 180x180 PNG for a favicon, 1920x1080 WebP for a login background — when the Sharp extension is installed, and stored as uploaded when it is not. An SVG is always stored as uploaded.\n\nServed afterwards from \`/_site//\`, which falls back to the built-in default until something is uploaded.`, tags: ['Sites'], consumes: [...imageMimeTypes, svgMimeType], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' }, kind: { type: 'string', description: 'Which of the site images to replace.', enum: [...siteAssetKinds] } }, required: ['siteId', 'kind'] }, response: { 200: { description: 'Image uploaded successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) if (!site) { return reply.notFound('Site does not exist.') } const data = req.body if (!Buffer.isBuffer(data) || data.length < 1) { throw new CustomError('siteImageEmpty', 'No image was sent.') } // -> The declared content type got the request this far; what the bytes actually are is what // decides, since they are what gets stored and served back if (!detectImageMime(data) && !detectSvg(data)) { throw new CustomError( 'siteImageInvalidImage', 'Not an SVG, PNG, JPEG, WebP or GIF image, whatever the request said it was.' ) } await WIKI.models.sites.setAsset(req.params.siteId, req.params.kind, data) await audit(req, 'admin', 'updateSiteImage', { siteId: req.params.siteId, kind: req.params.kind }) return { ok: true, message: 'Image uploaded successfully.' } } ) /** * CLEAR SITE IMAGE */ app.delete<{ Params: { siteId: string; kind: SiteAssetKind } }>( '/:siteId/images/:kind', { config: { permissions: ['manage:sites'] }, schema: { summary: "Remove one of a site's images", description: 'Leaves the built-in default to be served in its place again. Succeeds even if there was no image to remove.', tags: ['Sites'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' }, kind: { type: 'string', description: 'Which of the site images to remove.', enum: [...siteAssetKinds] } }, required: ['siteId', 'kind'] }, response: { 200: { description: 'Image cleared successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) if (!site) { return reply.notFound('Site does not exist.') } await WIKI.models.sites.clearAsset(req.params.siteId, req.params.kind) await audit(req, 'admin', 'deleteSiteImage', { siteId: req.params.siteId, kind: req.params.kind }) return { ok: true, message: 'Image cleared successfully.' } } ) /** * DELETE SITE */ app.delete<{ Params: { siteId: string } }>( '/:siteId', { config: { permissions: ['manage:sites'] }, schema: { summary: 'Delete a site', tags: ['Sites'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, response: { 204: { description: 'Site deleted successfully' } } } }, async (req, reply) => { // -> Read before it goes, so that the entry can say which site this was rather than only which // id it had const doomed = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) try { if ((await WIKI.models.sites.countSites()) <= 1) { reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.') } else if (await WIKI.models.sites.deleteSite(req.params.siteId)) { await audit(req, 'admin', 'deleteSite', { siteId: req.params.siteId, hostname: doomed?.hostname ?? null, title: doomed?.config?.title ?? null }) reply.code(204) } else { reply.badRequest('Site does not exist.') } } catch (err: any) { // -> Pages, assets, navigation, tags and the page tree all reference the site without a // cascade, so a site still holding content cannot be removed. That is a conflict to // report, not a server fault. if (err.cause?.code === '23503' || err.code === '23503') { return reply.conflict( 'Cannot delete a site that still holds content. Delete its pages and assets first.' ) } reply.send(err) } } ) } export default routes