mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
677 lines
19 KiB
677 lines
19 KiB
import { validate as uuidValidate } from 'uuid'
|
|
import { CustomError } 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',
|
|
'pageExtensions',
|
|
'logoText',
|
|
'sitemap',
|
|
'discoverable',
|
|
'auth',
|
|
'authStrategies',
|
|
'defaults',
|
|
'editors',
|
|
'features',
|
|
'locales',
|
|
'robots',
|
|
'theme',
|
|
'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: {
|
|
permissions: ['read: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
|
|
}))
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
} else {
|
|
return reply.notFound('Site does not exist.')
|
|
}
|
|
}
|
|
)
|
|
|
|
/**
|
|
* CREATE SITE
|
|
*/
|
|
app.post<{ Body: { hostname: string; title: string } }>(
|
|
'/',
|
|
{
|
|
config: {
|
|
permissions: ['create:sites', '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
|
|
})
|
|
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
|
|
pageExtensions?: string[]
|
|
logoText?: boolean
|
|
sitemap?: boolean
|
|
discoverable?: boolean
|
|
auth?: Record<string, any>
|
|
authStrategies?: Array<{ id: string; order?: number; isVisible?: boolean }>
|
|
defaults?: Record<string, any>
|
|
editors?: Record<string, { isActive?: boolean; config?: Record<string, any> }>
|
|
features?: Record<string, any>
|
|
locales?: {
|
|
primary?: string
|
|
active?: string[]
|
|
forcePrefix?: boolean
|
|
showMenu?: boolean
|
|
}
|
|
robots?: Record<string, any>
|
|
theme?: Record<string, any>
|
|
uploads?: Record<string, any>
|
|
}
|
|
}>(
|
|
'/:siteId',
|
|
{
|
|
config: {
|
|
permissions: ['manage:sites']
|
|
},
|
|
schema: {
|
|
summary: 'Update a site',
|
|
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'
|
|
},
|
|
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'
|
|
},
|
|
theme: {
|
|
$ref: 'Site#/properties/theme'
|
|
},
|
|
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.getLocales()).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<string, any> = {}
|
|
for (const key of SITE_CONFIG_KEYS) {
|
|
if (req.body[key] !== undefined) {
|
|
config[key] = req.body[key]
|
|
}
|
|
}
|
|
|
|
// -> Keep the legacy `features.ratings` flag in sync with the ratings mode
|
|
if (config.features?.ratingsMode !== undefined) {
|
|
config.features.ratings = config.features.ratingsMode !== 'off'
|
|
}
|
|
|
|
// -> 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 })
|
|
})
|
|
return {
|
|
ok: true,
|
|
message: 'Site updated successfully.'
|
|
}
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(err)
|
|
return reply.internalServerError()
|
|
}
|
|
}
|
|
)
|
|
|
|
/**
|
|
* 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/<siteId>/<kind>\`, 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)
|
|
|
|
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)
|
|
|
|
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) => {
|
|
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)) {
|
|
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
|