mirror of https://github.com/requarks/wiki
parent
45b5bd5cdc
commit
d1c41b4111
@ -0,0 +1,192 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import type { KeyExpiration } from '../models/apiKeys.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Keys Routes
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* LIST API KEYS
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List all API keys',
|
||||||
|
description:
|
||||||
|
'Revoked and expired keys are listed too, so that the admin area can show their state.',
|
||||||
|
tags: ['API Keys'],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of API keys',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'ApiKey#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return WIKI.models.apiKeys.getKeys()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE API KEY
|
||||||
|
*/
|
||||||
|
app.post<{
|
||||||
|
Body: { name: string; expiration: KeyExpiration; groups: string[] }
|
||||||
|
}>(
|
||||||
|
'/',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Create a new API key',
|
||||||
|
description:
|
||||||
|
'The response carries the token, which is the only time it can be read: only its last characters are stored. The key holds the combined permissions of the groups given.',
|
||||||
|
tags: ['API Keys'],
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['name', 'expiration', 'groups'],
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'What the key is for.'
|
||||||
|
},
|
||||||
|
expiration: { $ref: 'ApiKeyExpiration#' },
|
||||||
|
groups: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
description:
|
||||||
|
'Groups whose permissions the key carries. The guests group is not accepted.',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'API key created successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The token. Shown once and never again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!/^[^<>"]+$/.test(req.body.name)) {
|
||||||
|
return reply.badRequest('Key name contains invalid characters.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> A key inherits group permissions, so every group must exist; a stale client should not
|
||||||
|
// silently mint a key with fewer permissions than the operator picked
|
||||||
|
const known = await WIKI.models.groups.getAllGroups()
|
||||||
|
for (const groupId of req.body.groups) {
|
||||||
|
if (!known.some((g) => g.id === groupId)) {
|
||||||
|
return reply.badRequest('One of the groups does not exist.')
|
||||||
|
}
|
||||||
|
// -> Guests are anonymous visitors: a key holding their permissions grants nothing a caller
|
||||||
|
// could not already do without one
|
||||||
|
if (groupId === WIKI.data.systemIds.guestsGroupId) {
|
||||||
|
return reply.badRequest('The guests group cannot be used for API keys.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, key } = await WIKI.models.apiKeys.createKey({
|
||||||
|
name: req.body.name,
|
||||||
|
expiration: req.body.expiration,
|
||||||
|
groups: req.body.groups
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'API key created successfully.',
|
||||||
|
id,
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REVOKE API KEY
|
||||||
|
*/
|
||||||
|
app.post<{ Params: { keyId: string } }>(
|
||||||
|
'/:keyId/revoke',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Revoke an API key',
|
||||||
|
description:
|
||||||
|
'Permanent: the key stays listed as revoked and stops authenticating on the next request. Keys are never deleted, so the record of what existed is kept.',
|
||||||
|
tags: ['API Keys'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
keyId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['keyId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'API key revoked successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const key = await WIKI.models.apiKeys.getKeyById(req.params.keyId)
|
||||||
|
if (!key) {
|
||||||
|
return reply.notFound('API key does not exist.')
|
||||||
|
}
|
||||||
|
if (key.isRevoked) {
|
||||||
|
return reply.conflict('This API key is already revoked.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.models.apiKeys.revokeKey(key.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'API key revoked successfully.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,320 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { EMITTED_EVENTS, HOOK_EVENTS } from '../models/hooks.ts'
|
||||||
|
|
||||||
|
interface HookBody {
|
||||||
|
name?: string
|
||||||
|
events?: string[]
|
||||||
|
url?: string
|
||||||
|
includeMetadata?: boolean
|
||||||
|
includeContent?: boolean
|
||||||
|
acceptUntrusted?: boolean
|
||||||
|
authHeader?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject what the admin area's own validation rejects, so the API is not the looser of the two
|
||||||
|
*/
|
||||||
|
function invalidReason(body: HookBody, { partial }: { partial: boolean }): string | null {
|
||||||
|
if (body.name !== undefined && !/^[^<>"]+$/.test(body.name)) {
|
||||||
|
return 'The webhook name contains invalid characters.'
|
||||||
|
}
|
||||||
|
if (body.url !== undefined) {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(body.url)
|
||||||
|
} catch {
|
||||||
|
return 'The URL is not valid.'
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return 'The URL must be an http or https address.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!partial && (body.events?.length ?? 0) < 1) {
|
||||||
|
return 'At least one event is required.'
|
||||||
|
}
|
||||||
|
if (body.events !== undefined && body.events.length < 1) {
|
||||||
|
return 'At least one event is required.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhooks API Routes
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* LIST WEBHOOKS
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List all webhooks',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of webhooks',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'Hook#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return WIKI.models.hooks.getHooks()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIST AVAILABLE EVENTS
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/events',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List the events a webhook can subscribe to',
|
||||||
|
description:
|
||||||
|
'Only `user:join` and `user:login` are emitted at the moment. Pages, assets, comments and logout are not implemented yet, so a subscription to those is stored but never triggered.',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of event keys',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
isEmitted: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether anything in the server currently emits this event.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return HOOK_EVENTS.map((key) => ({ key, isEmitted: EMITTED_EVENTS.includes(key) }))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET WEBHOOK
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { hookId: string } }>(
|
||||||
|
'/:hookId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Get a single webhook',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hookId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['hookId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { $ref: 'Hook#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const hook = await WIKI.models.hooks.getHookById(req.params.hookId)
|
||||||
|
if (!hook) {
|
||||||
|
return reply.notFound('Webhook does not exist.')
|
||||||
|
}
|
||||||
|
return hook
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREATE WEBHOOK
|
||||||
|
*/
|
||||||
|
app.post<{ Body: HookBody }>(
|
||||||
|
'/',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Create a new webhook',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
// -> The same shape as an update, with the three fields a webhook cannot exist without
|
||||||
|
body: {
|
||||||
|
allOf: [{ $ref: 'HookInput#' }, { required: ['name', 'events', 'url'] }]
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Webhook created successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const invalid = invalidReason(req.body, { partial: false })
|
||||||
|
if (invalid) {
|
||||||
|
return reply.badRequest(invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await WIKI.models.hooks.createHook({
|
||||||
|
name: req.body.name!,
|
||||||
|
events: req.body.events!,
|
||||||
|
url: req.body.url!,
|
||||||
|
includeMetadata: req.body.includeMetadata,
|
||||||
|
includeContent: req.body.includeContent,
|
||||||
|
acceptUntrusted: req.body.acceptUntrusted,
|
||||||
|
authHeader: req.body.authHeader
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Webhook created successfully.',
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE WEBHOOK
|
||||||
|
*/
|
||||||
|
app.put<{ Params: { hookId: string }; Body: HookBody }>(
|
||||||
|
'/:hookId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Update a webhook',
|
||||||
|
description:
|
||||||
|
'Accepts any subset of the fields. Changing the URL, the events or the authentication header resets the webhook to pending, since the last outcome no longer describes the new configuration.',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hookId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['hookId']
|
||||||
|
},
|
||||||
|
body: { $ref: 'HookInput#' },
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Webhook updated successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!(await WIKI.models.hooks.getHookById(req.params.hookId))) {
|
||||||
|
return reply.notFound('Webhook does not exist.')
|
||||||
|
}
|
||||||
|
const invalid = invalidReason(req.body, { partial: true })
|
||||||
|
if (invalid) {
|
||||||
|
return reply.badRequest(invalid)
|
||||||
|
}
|
||||||
|
const patch: Record<string, any> = {}
|
||||||
|
for (const field of [
|
||||||
|
'name',
|
||||||
|
'events',
|
||||||
|
'url',
|
||||||
|
'includeMetadata',
|
||||||
|
'includeContent',
|
||||||
|
'acceptUntrusted',
|
||||||
|
'authHeader'
|
||||||
|
] as const) {
|
||||||
|
if (req.body[field] !== undefined) {
|
||||||
|
patch[field] = req.body[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length < 1) {
|
||||||
|
return reply.badRequest('No webhook fields provided to update.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await WIKI.models.hooks.updateHook(req.params.hookId, patch)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Webhook updated successfully.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE WEBHOOK
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { hookId: string } }>(
|
||||||
|
'/:hookId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Delete a webhook',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hookId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['hookId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
204: {
|
||||||
|
description: 'Webhook deleted successfully'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!(await WIKI.models.hooks.deleteHook(req.params.hookId))) {
|
||||||
|
return reply.notFound('Webhook does not exist.')
|
||||||
|
}
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { KEY_EXPIRATIONS } from '../../models/apiKeys.ts'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* API KEY - Metadata only; the token itself exists once, in the create response
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'ApiKey',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
keyShort: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Last characters of the token, to tell keys apart. The token is not stored.'
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'IDs of the groups this key draws its permissions from.',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expiration: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
isRevoked: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API KEY EXPIRATION - The lifetimes a new key can be given
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'ApiKeyExpiration',
|
||||||
|
type: 'string',
|
||||||
|
enum: Object.keys(KEY_EXPIRATIONS)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* AUTH MODULE - An authentication module as found on disk
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'AuthModule',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory name under `modules/authentication`.'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
vendor: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
isAvailable: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
useForm: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether logging in through it means submitting a username and password.'
|
||||||
|
},
|
||||||
|
usernameType: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'The module configuration, declared in its `definition.yml`: each entry carries a `type`, `title`, `hint`, `default` and the display hints the admin area renders a control from. A `readOnly` prop is shown but cannot be changed, and is silently kept at its stored value when written to.'
|
||||||
|
},
|
||||||
|
refs: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'Read-only values the administrator needs to configure the other side, such as a callback URL. `{host}` and `{id}` are placeholders for the wiki origin and the strategy ID.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTH STRATEGY - A configured instance of a module
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'AuthStrategy',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Key of the module this strategy is an instance of.'
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
isEnabled: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
registration: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
allowedEmailRegex: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
autoEnrollGroups: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTH STRATEGY INPUT - Used both ways: to create a strategy, and as a partial update
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'AuthStrategyInput',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
module: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
description:
|
||||||
|
'Only on create, and only a module that exists on disk. Cannot be changed after.'
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'Defaults to the module title on create.'
|
||||||
|
},
|
||||||
|
isEnabled: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
registration: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Stored but not enforced: self-registration is not implemented yet.'
|
||||||
|
},
|
||||||
|
allowedEmailRegex: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
description:
|
||||||
|
'Must be a valid regular expression. Stored but not enforced, as it only applies to self-registration.'
|
||||||
|
},
|
||||||
|
autoEnrollGroups: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Groups a self-registered user would join. The guests group is refused. Stored but not enforced, as above.'
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
description:
|
||||||
|
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* EXTENSION - Optional third-party tooling, with its state on this system
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'Extension',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory name under `modules/extensions`.'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Where the extension itself is documented. Empty when not declared.'
|
||||||
|
},
|
||||||
|
isInstalled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether it was found on this system. Always false when incompatible.'
|
||||||
|
},
|
||||||
|
isInstallable: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether the admin area can install it, rather than it being installed by hand.'
|
||||||
|
},
|
||||||
|
isCompatible: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether this platform and architecture can run it at all.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { FLAGS } from '../../models/flags.ts'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* SYSTEM FLAGS - Used both ways: as the response, and as a partial update body
|
||||||
|
*
|
||||||
|
* Built from the model's own list, so a new flag is exposed and documented by declaring it there.
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'SystemFlags',
|
||||||
|
type: 'object',
|
||||||
|
properties: Object.fromEntries(
|
||||||
|
Object.entries(FLAGS).map(([key, description]) => [key, { type: 'boolean', description }])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { HOOK_EVENTS } from '../../models/hooks.ts'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* HOOK INPUT - The writable fields, used for both create and update
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'HookInput',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
enum: HOOK_EVENTS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 2048,
|
||||||
|
description: 'Where to POST the event. Must be an http or https address.'
|
||||||
|
},
|
||||||
|
includeMetadata: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include the event metadata, such as a page title and author.'
|
||||||
|
},
|
||||||
|
includeContent: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include the full content, e.g. a page body. Payloads can get large.'
|
||||||
|
},
|
||||||
|
acceptUntrusted: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Skip TLS certificate validation for this endpoint.'
|
||||||
|
},
|
||||||
|
authHeader: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 2048,
|
||||||
|
description: 'Sent verbatim as the Authorization header.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOOK - A webhook with the outcome of its last delivery
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'Hook',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' }
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
includeMetadata: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
includeContent: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
acceptUntrusted: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
authHeader: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['pending', 'success', 'error'],
|
||||||
|
description:
|
||||||
|
'`pending` until an event reaches it, then the outcome of the most recent delivery.'
|
||||||
|
},
|
||||||
|
lastErrorMessage: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Why the last delivery failed. Null unless the state is `error`.'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { CORS_MODES } from '../../helpers/security.ts'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* SECURITY CONFIG - Used both ways: as the response, and as a partial update body
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'SecurityConfig',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
corsMode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: CORS_MODES,
|
||||||
|
description:
|
||||||
|
'`OFF` sends no CORS headers at all, i.e. same-origin only. `REFLECT` echoes the request origin back.'
|
||||||
|
},
|
||||||
|
corsConfig: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 8192,
|
||||||
|
description:
|
||||||
|
'Hostnames, one per line or comma-separated, for `HOSTNAMES` mode; a regular expression for `REGEX` mode. Ignored otherwise.'
|
||||||
|
},
|
||||||
|
enforceCsp: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
cspDirectives: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 8192,
|
||||||
|
description: "Directives separated by `;`, e.g. `default-src 'self'; img-src * data:`."
|
||||||
|
},
|
||||||
|
enforceHsts: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
hstsDuration: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
description: 'Seconds. Must be greater than zero when HSTS is enforced.'
|
||||||
|
},
|
||||||
|
disallowFloc: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Sends `Permissions-Policy: interest-cohort=()`.'
|
||||||
|
},
|
||||||
|
disallowIframe: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '`X-Frame-Options: DENY` when on, `SAMEORIGIN` when off.'
|
||||||
|
},
|
||||||
|
enforceSameOriginReferrerPolicy: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '`Referrer-Policy: same-origin` when on, `no-referrer` when off.'
|
||||||
|
},
|
||||||
|
disallowOpenRedirect: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Stored, but nothing redirects on user input yet.'
|
||||||
|
},
|
||||||
|
forceAssetDownload: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Stored, but asset serving is not implemented yet.'
|
||||||
|
},
|
||||||
|
trustProxy: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether to trust `X-Forwarded-*` headers.'
|
||||||
|
},
|
||||||
|
uploadMaxFileSize: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
description: 'Bytes. Stored, but there is no upload endpoint yet.'
|
||||||
|
},
|
||||||
|
uploadMaxFiles: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 1,
|
||||||
|
description: 'Stored, but there is no upload endpoint yet.'
|
||||||
|
},
|
||||||
|
uploadScanSVG: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Stored, but there is no upload endpoint yet.'
|
||||||
|
},
|
||||||
|
authJwtAudience: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
description:
|
||||||
|
'Audience claim of issued tokens. Changing it invalidates every API key already issued.'
|
||||||
|
},
|
||||||
|
authJwtExpiration: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 16,
|
||||||
|
description: 'Duration, e.g. `30m`.'
|
||||||
|
},
|
||||||
|
authJwtRenewablePeriod: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 16,
|
||||||
|
description: 'Duration, e.g. `14d`.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "apiKeys" DROP COLUMN "key";
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "apiKeys" ADD COLUMN "keyShort" varchar(8) NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "apiKeys" ADD COLUMN "groups" jsonb DEFAULT '[]' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TYPE "hookState" AS ENUM('pending', 'success', 'error');--> statement-breakpoint
|
||||||
|
CREATE TABLE "hooks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"events" text[] DEFAULT ARRAY[]::text[] NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"includeMetadata" boolean DEFAULT true NOT NULL,
|
||||||
|
"includeContent" boolean DEFAULT false NOT NULL,
|
||||||
|
"acceptUntrusted" boolean DEFAULT false NOT NULL,
|
||||||
|
"authHeader" text,
|
||||||
|
"state" "hookState" DEFAULT 'pending'::"hookState" NOT NULL,
|
||||||
|
"lastErrorMessage" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,108 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal RS256 JWT signing and verification.
|
||||||
|
*
|
||||||
|
* Wiki.js generates an RSA keypair during installation and keeps it in `config.auth.certs`, so
|
||||||
|
* tokens are signed with that key rather than with a shared secret. Only the RS256 algorithm is
|
||||||
|
* accepted on the way in — a token asking for `none`, or for an HMAC algorithm that would turn the
|
||||||
|
* public key into a signing secret, is rejected outright.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface JwtClaims {
|
||||||
|
[claim: string]: any
|
||||||
|
/** Audience. Compared against the expected one during verification. */
|
||||||
|
aud?: string
|
||||||
|
/** Expiry, in seconds since the epoch. Required by `verifyJwt`. */
|
||||||
|
exp?: number
|
||||||
|
/** Issued at, in seconds since the epoch. */
|
||||||
|
iat?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSegment(value: object): string {
|
||||||
|
return Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSegment(segment: string): any {
|
||||||
|
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds since the epoch, the unit JWT uses for `iat` / `exp`. */
|
||||||
|
export function epochSeconds(instant: Temporal.Instant = Temporal.Now.instant()): number {
|
||||||
|
return Math.floor(instant.epochMilliseconds / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a set of claims.
|
||||||
|
*
|
||||||
|
* @param privateKey A key object, or a PEM string for an unencrypted key. The installation key is
|
||||||
|
* passphrase-protected, so callers pass a `KeyObject` built with the passphrase.
|
||||||
|
*/
|
||||||
|
export function signJwt(claims: JwtClaims, privateKey: crypto.KeyObject | string): string {
|
||||||
|
const payload = `${encodeSegment({ alg: 'RS256', typ: 'JWT' })}.${encodeSegment(claims)}`
|
||||||
|
const signature = crypto.sign('RSA-SHA256', Buffer.from(payload), privateKey)
|
||||||
|
return `${payload}.${signature.toString('base64url')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a token and return its claims.
|
||||||
|
*
|
||||||
|
* Throws with a specific message on every failure — a malformed token, a bad signature, an expired
|
||||||
|
* token or the wrong audience — so callers can log the reason without inspecting the token again.
|
||||||
|
*/
|
||||||
|
export function verifyJwt(
|
||||||
|
token: string,
|
||||||
|
publicKey: crypto.KeyObject | string,
|
||||||
|
{ audience }: { audience?: string } = {}
|
||||||
|
): JwtClaims {
|
||||||
|
const segments = token.split('.')
|
||||||
|
if (segments.length !== 3) {
|
||||||
|
throw new Error('Token is malformed.')
|
||||||
|
}
|
||||||
|
const [encodedHeader, encodedClaims, encodedSignature] = segments as [string, string, string]
|
||||||
|
|
||||||
|
let header: any
|
||||||
|
let claims: JwtClaims
|
||||||
|
try {
|
||||||
|
header = decodeSegment(encodedHeader)
|
||||||
|
claims = decodeSegment(encodedClaims)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Token is malformed.')
|
||||||
|
}
|
||||||
|
if (header?.alg !== 'RS256') {
|
||||||
|
throw new Error('Token algorithm is not supported.')
|
||||||
|
}
|
||||||
|
if (!claims || typeof claims !== 'object') {
|
||||||
|
throw new Error('Token is malformed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let isValid = false
|
||||||
|
try {
|
||||||
|
isValid = crypto.verify(
|
||||||
|
'RSA-SHA256',
|
||||||
|
Buffer.from(`${encodedHeader}.${encodedClaims}`),
|
||||||
|
publicKey,
|
||||||
|
Buffer.from(encodedSignature, 'base64url')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// -> A signature that is not even well-formed lands here rather than returning false
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Token signature is invalid.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> A token with no expiry would be valid forever; treat its absence as a failure rather than as
|
||||||
|
// permission to skip the check
|
||||||
|
if (typeof claims.exp !== 'number') {
|
||||||
|
throw new Error('Token has no expiration.')
|
||||||
|
}
|
||||||
|
if (epochSeconds() >= claims.exp) {
|
||||||
|
throw new Error('Token has expired.')
|
||||||
|
}
|
||||||
|
if (audience && claims.aud !== audience) {
|
||||||
|
throw new Error('Token audience does not match.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Helpers turning the security settings an operator edits in the admin area into the shapes the
|
||||||
|
* HTTP plugins expect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** CORS modes offered by the admin area, in the order they appear there. */
|
||||||
|
export const CORS_MODES = ['OFF', 'REFLECT', 'HOSTNAMES', 'REGEX'] as const
|
||||||
|
export type CorsMode = (typeof CORS_MODES)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a Content-Security-Policy string into helmet's directives object.
|
||||||
|
*
|
||||||
|
* `default-src 'self'; img-src * data:` becomes
|
||||||
|
* `{ 'default-src': ["'self'"], 'img-src': ['*', 'data:'] }`. A directive with no value, such as
|
||||||
|
* `upgrade-insecure-requests`, maps to an empty list, which is how helmet expresses it too.
|
||||||
|
*/
|
||||||
|
export function parseCspDirectives(value: string): Record<string, string[]> {
|
||||||
|
const directives: Record<string, string[]> = {}
|
||||||
|
for (const chunk of value.split(';')) {
|
||||||
|
const parts = chunk.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const name = parts.shift()
|
||||||
|
if (!name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
directives[name.toLowerCase()] = parts
|
||||||
|
}
|
||||||
|
return directives
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `origin` option for `@fastify/cors`, from the configured mode.
|
||||||
|
*
|
||||||
|
* `false` means no CORS headers at all, i.e. same-origin only, which is both the `OFF` mode and what
|
||||||
|
* anything unrecognised degrades to — a misconfiguration should not end up more permissive than the
|
||||||
|
* operator asked for.
|
||||||
|
*/
|
||||||
|
export function corsOrigin(security: {
|
||||||
|
corsMode?: string
|
||||||
|
corsConfig?: string
|
||||||
|
}): boolean | string[] | RegExp {
|
||||||
|
switch (security.corsMode) {
|
||||||
|
case 'REFLECT':
|
||||||
|
return true
|
||||||
|
case 'HOSTNAMES':
|
||||||
|
return (security.corsConfig ?? '')
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
case 'REGEX':
|
||||||
|
try {
|
||||||
|
return new RegExp(security.corsConfig ?? '')
|
||||||
|
} catch (err: any) {
|
||||||
|
WIKI.logger.warn(
|
||||||
|
`The CORS regex pattern is invalid (${err.message}) — falling back to same-origin only.`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { apiKeys as apiKeysTable, groups as groupsTable } from '../db/schema.ts'
|
||||||
|
import { desc, eq, inArray, sql } from 'drizzle-orm'
|
||||||
|
import { flatten, uniq } from 'es-toolkit/array'
|
||||||
|
import { epochSeconds, signJwt, verifyJwt } from '../helpers/jwt.ts'
|
||||||
|
|
||||||
|
/** The lifetimes the admin area offers, as durations the API accepts. */
|
||||||
|
export const KEY_EXPIRATIONS = {
|
||||||
|
'30d': { days: 30 },
|
||||||
|
'90d': { days: 90 },
|
||||||
|
'180d': { days: 180 },
|
||||||
|
'1y': { years: 1 },
|
||||||
|
'3y': { years: 3 }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KeyExpiration = keyof typeof KEY_EXPIRATIONS
|
||||||
|
|
||||||
|
/** An API key as exposed by the API. Never includes the token itself, which is not stored. */
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
keyShort: string
|
||||||
|
groups: string[]
|
||||||
|
expiration: Date
|
||||||
|
isRevoked: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a verified key grants, resolved from its groups at request time. */
|
||||||
|
export interface ApiKeyIdentity {
|
||||||
|
id: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raised by `verify()` when a token is not usable, with a reason safe to return to the caller. */
|
||||||
|
export class ApiKeyError extends Error {}
|
||||||
|
|
||||||
|
const keySelection = {
|
||||||
|
id: apiKeysTable.id,
|
||||||
|
name: apiKeysTable.name,
|
||||||
|
keyShort: apiKeysTable.keyShort,
|
||||||
|
groups: apiKeysTable.groups,
|
||||||
|
expiration: apiKeysTable.expiration,
|
||||||
|
isRevoked: apiKeysTable.isRevoked,
|
||||||
|
createdAt: apiKeysTable.createdAt,
|
||||||
|
updatedAt: apiKeysTable.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Keys model
|
||||||
|
*
|
||||||
|
* A key is an RS256 JWT signed with the installation keypair, carrying the key row's ID and the
|
||||||
|
* groups it draws permissions from. The token is shown once at creation and never stored: the
|
||||||
|
* signature proves authenticity, and the row is consulted for revocation and expiry. Permissions are
|
||||||
|
* resolved from the groups on every request, so changing a group takes effect immediately.
|
||||||
|
*/
|
||||||
|
class ApiKeys {
|
||||||
|
/**
|
||||||
|
* The signing key, built from the passphrase-protected PEM in `config.auth.certs`
|
||||||
|
*/
|
||||||
|
private privateKey(): crypto.KeyObject {
|
||||||
|
return crypto.createPrivateKey({
|
||||||
|
key: WIKI.config.auth.certs.private,
|
||||||
|
passphrase: WIKI.config.auth.secret
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every key, newest first. Revoked and expired keys are kept: the admin list shows their state.
|
||||||
|
*/
|
||||||
|
async getKeys(): Promise<ApiKey[]> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select(keySelection)
|
||||||
|
.from(apiKeysTable)
|
||||||
|
.orderBy(desc(apiKeysTable.createdAt))
|
||||||
|
return results as ApiKey[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a new key.
|
||||||
|
*
|
||||||
|
* @returns The key row plus the token, which is the only time it exists outside the client
|
||||||
|
*/
|
||||||
|
async createKey({
|
||||||
|
name,
|
||||||
|
expiration,
|
||||||
|
groups
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
expiration: KeyExpiration
|
||||||
|
groups: string[]
|
||||||
|
}): Promise<{ id: string; key: string }> {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const expiresAt = Temporal.Now.zonedDateTimeISO('UTC')
|
||||||
|
.add(KEY_EXPIRATIONS[expiration])
|
||||||
|
.toInstant()
|
||||||
|
|
||||||
|
const key = signJwt(
|
||||||
|
{
|
||||||
|
// -> `api` marks the token as a key rather than a user token, so the two can never be
|
||||||
|
// confused should user tokens ever be signed with the same keypair
|
||||||
|
api: 1,
|
||||||
|
id,
|
||||||
|
grp: groups,
|
||||||
|
aud: WIKI.config.auth.audience,
|
||||||
|
iat: epochSeconds(),
|
||||||
|
exp: epochSeconds(expiresAt)
|
||||||
|
},
|
||||||
|
this.privateKey()
|
||||||
|
)
|
||||||
|
|
||||||
|
await WIKI.db.insert(apiKeysTable).values({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
keyShort: key.slice(-8),
|
||||||
|
groups,
|
||||||
|
expiration: new Date(expiresAt.epochMilliseconds),
|
||||||
|
isRevoked: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single key, or null if there is no such key
|
||||||
|
*/
|
||||||
|
async getKeyById(id: string): Promise<ApiKey | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select(keySelection)
|
||||||
|
.from(apiKeysTable)
|
||||||
|
.where(eq(apiKeysTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
return (results[0] as ApiKey) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a key, permanently. Tokens already handed out stop working on the next request.
|
||||||
|
*
|
||||||
|
* @returns Whether a key was revoked
|
||||||
|
*/
|
||||||
|
async revokeKey(id: string): Promise<boolean> {
|
||||||
|
const result = await WIKI.db
|
||||||
|
.update(apiKeysTable)
|
||||||
|
.set({ isRevoked: true, updatedAt: sql`now()` })
|
||||||
|
.where(eq(apiKeysTable.id, id))
|
||||||
|
return (result.rowCount ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The union of the permissions held by the given groups.
|
||||||
|
*
|
||||||
|
* A group that no longer exists simply contributes nothing, so deleting a group narrows the keys
|
||||||
|
* pointing at it instead of breaking them.
|
||||||
|
*/
|
||||||
|
async resolvePermissions(groupIds: string[]): Promise<string[]> {
|
||||||
|
if (groupIds.length < 1) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({ permissions: groupsTable.permissions })
|
||||||
|
.from(groupsTable)
|
||||||
|
.where(inArray(groupsTable.id, groupIds))
|
||||||
|
return uniq(flatten(rows.map((r: any) => (r.permissions ?? []) as string[])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a bearer token and resolve what it grants.
|
||||||
|
*
|
||||||
|
* @throws ApiKeyError with a reason suitable for a 401 response
|
||||||
|
*/
|
||||||
|
async verify(token: string): Promise<ApiKeyIdentity> {
|
||||||
|
if (WIKI.config.api.isEnabled !== true) {
|
||||||
|
throw new ApiKeyError('The API is disabled.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims
|
||||||
|
try {
|
||||||
|
claims = verifyJwt(token, WIKI.config.auth.certs.public, {
|
||||||
|
audience: WIKI.config.auth.audience
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new ApiKeyError(err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claims.api !== 1 || typeof claims.id !== 'string') {
|
||||||
|
throw new ApiKeyError('Token is not an API key.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await this.getKeyById(claims.id)
|
||||||
|
if (!key) {
|
||||||
|
throw new ApiKeyError('API key does not exist.')
|
||||||
|
}
|
||||||
|
if (key.isRevoked) {
|
||||||
|
throw new ApiKeyError('API key has been revoked.')
|
||||||
|
}
|
||||||
|
// -> The token carries its own expiry, but the row is what the admin area shows; a mismatch
|
||||||
|
// should fail closed rather than trust the token
|
||||||
|
if (Temporal.Instant.compare(key.expiration.toTemporalInstant(), Temporal.Now.instant()) <= 0) {
|
||||||
|
throw new ApiKeyError('API key has expired.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: key.id,
|
||||||
|
permissions: await this.resolvePermissions(
|
||||||
|
Array.isArray(claims.grp) ? (claims.grp as string[]) : []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeys = new ApiKeys()
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
/** How an extension's presence on this system is detected. */
|
||||||
|
export interface ExtensionDetection {
|
||||||
|
/** `command` looks for an executable on PATH, `module` for a resolvable npm package. */
|
||||||
|
type: 'command' | 'module'
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An extension as declared by its `definition.yml`. */
|
||||||
|
export interface ExtensionDefinition {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
website?: string
|
||||||
|
detect: ExtensionDetection
|
||||||
|
/** Architectures the extension can run on. Any architecture when absent. */
|
||||||
|
architectures?: string[]
|
||||||
|
/** Platforms the extension can run on. Any platform when absent. */
|
||||||
|
platforms?: string[]
|
||||||
|
/** Whether the admin area can install it, as opposed to it being installed by hand. */
|
||||||
|
isInstallable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An extension plus its state on this system, as exposed by the API. */
|
||||||
|
export interface ExtensionState {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
website: string
|
||||||
|
isInstalled: boolean
|
||||||
|
isInstallable: boolean
|
||||||
|
isCompatible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an executable of this name exists on PATH.
|
||||||
|
*
|
||||||
|
* Walks PATH rather than shelling out to `which` / `where`, which is both faster and free of any
|
||||||
|
* quoting concerns around the name being looked up.
|
||||||
|
*/
|
||||||
|
async function commandExists(command: string): Promise<boolean> {
|
||||||
|
const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
||||||
|
// -> On Windows the name on disk carries an extension, e.g. `git.exe`
|
||||||
|
const suffixes =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
||||||
|
: ['']
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(dir, `${command}${suffix}`), fs.constants.X_OK)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// -> Not in this directory, or not executable by us; keep looking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an npm package is installed in the backend's `node_modules`.
|
||||||
|
*
|
||||||
|
* Not `import()`: optional dependencies like Sharp load native binaries, which is expensive and can
|
||||||
|
* fail for reasons that have nothing to do with the package being there. Not `import.meta.resolve`
|
||||||
|
* either — it caches package.json lookups, so a package removed after being resolved once keeps
|
||||||
|
* reporting as present until the server restarts, which is the misleading direction here. Reading the
|
||||||
|
* manifest is cheap and always current.
|
||||||
|
*/
|
||||||
|
async function moduleExists(specifier: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(WIKI.SERVERPATH, 'node_modules', specifier, 'package.json'))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions model
|
||||||
|
*
|
||||||
|
* Optional third-party tooling that unlocks extra functionality — a Git binary, Pandoc, Sharp,
|
||||||
|
* Puppeteer. Each lives in `modules/extensions/<key>/definition.yml`, which declares how to detect
|
||||||
|
* it and what it is compatible with. Nothing here installs anything: these are installed with the
|
||||||
|
* system package manager or as optional dependencies, which is what the admin area links out to.
|
||||||
|
*/
|
||||||
|
class Extensions {
|
||||||
|
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */
|
||||||
|
definitions: ExtensionDefinition[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the extension definitions from disk.
|
||||||
|
*/
|
||||||
|
async refreshFromDisk(): Promise<void> {
|
||||||
|
const extensionsPath = path.join(WIKI.SERVERPATH, 'modules/extensions')
|
||||||
|
const definitions: ExtensionDefinition[] = []
|
||||||
|
try {
|
||||||
|
for (const dir of await fs.readdir(extensionsPath)) {
|
||||||
|
const raw = await fs.readFile(path.join(extensionsPath, dir, 'definition.yml'), 'utf8')
|
||||||
|
const parsed = yaml.load(raw) as ExtensionDefinition
|
||||||
|
// -> The directory name is the key, as it is for every other module type
|
||||||
|
parsed.key = dir
|
||||||
|
definitions.push(parsed)
|
||||||
|
}
|
||||||
|
this.definitions = definitions.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
WIKI.logger.info(`Found ${this.definitions.length} extensions [ OK ]`)
|
||||||
|
} catch (err: any) {
|
||||||
|
this.definitions = []
|
||||||
|
WIKI.logger.warn(`Could not read the extension definitions at ${extensionsPath} [ SKIPPED ]`)
|
||||||
|
WIKI.logger.warn(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this system can run the extension at all, regardless of whether it is installed
|
||||||
|
*/
|
||||||
|
isCompatible(definition: ExtensionDefinition): boolean {
|
||||||
|
if (definition.architectures && !definition.architectures.includes(os.arch())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (definition.platforms && !definition.platforms.includes(process.platform)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the extension is present on this system
|
||||||
|
*/
|
||||||
|
async isInstalled(definition: ExtensionDefinition): Promise<boolean> {
|
||||||
|
switch (definition.detect?.type) {
|
||||||
|
case 'command':
|
||||||
|
return commandExists(definition.detect.value)
|
||||||
|
case 'module':
|
||||||
|
return moduleExists(definition.detect.value)
|
||||||
|
default:
|
||||||
|
WIKI.logger.warn(`Extension ${definition.key} has no usable detection method.`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every extension with its current state.
|
||||||
|
*
|
||||||
|
* Detection runs on each call rather than being cached at boot, so that installing a tool and
|
||||||
|
* hitting refresh in the admin area reflects reality without restarting the server.
|
||||||
|
*/
|
||||||
|
async getExtensions(): Promise<ExtensionState[]> {
|
||||||
|
const results: ExtensionState[] = []
|
||||||
|
for (const definition of this.definitions) {
|
||||||
|
const isCompatible = this.isCompatible(definition)
|
||||||
|
results.push({
|
||||||
|
key: definition.key,
|
||||||
|
title: definition.title,
|
||||||
|
description: definition.description,
|
||||||
|
website: definition.website ?? '',
|
||||||
|
// -> An incompatible extension cannot be present, and skipping the check keeps a pointless
|
||||||
|
// PATH walk out of the way
|
||||||
|
isInstalled: isCompatible ? await this.isInstalled(definition) : false,
|
||||||
|
isInstallable: definition.isInstallable === true,
|
||||||
|
isCompatible
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single definition, or null if there is no extension with this key
|
||||||
|
*/
|
||||||
|
getDefinition(key: string): ExtensionDefinition | null {
|
||||||
|
return this.definitions.find((d) => d.key === key) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log which extensions were found, the way the other module types report at boot
|
||||||
|
*/
|
||||||
|
async logState(): Promise<void> {
|
||||||
|
for (const extension of await this.getExtensions()) {
|
||||||
|
if (!extension.isCompatible) {
|
||||||
|
WIKI.logger.info(
|
||||||
|
`Extension ${extension.key} is not compatible with this system. [ SKIPPED ]`
|
||||||
|
)
|
||||||
|
} else if (extension.isInstalled) {
|
||||||
|
WIKI.logger.info(`Extension ${extension.key} is installed. [ OK ]`)
|
||||||
|
} else {
|
||||||
|
WIKI.logger.info(`Extension ${extension.key} was not found on this system. [ SKIPPED ]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extensions = new Extensions()
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* The system flags, and what enabling each one actually does.
|
||||||
|
*
|
||||||
|
* Flags are read live: nothing here needs a restart, and every one of them has an effect somewhere in
|
||||||
|
* the running server. Anything added to this list needs a consumer, otherwise the admin area offers a
|
||||||
|
* switch that changes nothing.
|
||||||
|
*/
|
||||||
|
export const FLAGS = {
|
||||||
|
/** Consumed by the frontend, which reveals unfinished features when it is on. */
|
||||||
|
experimental: 'Unfinished features are offered in the interface.',
|
||||||
|
/** Consumed by `models/users.ts` and `api/authentication.ts` via `authDebug()` below. */
|
||||||
|
authDebug: 'Login and account creation attempts are logged in detail.',
|
||||||
|
/** Consumed by the query logger in `core/db.ts`. */
|
||||||
|
sqlLog: 'Every database query is logged.'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Flag = keyof typeof FLAGS
|
||||||
|
|
||||||
|
export const FLAG_KEYS = Object.keys(FLAGS) as Flag[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags model
|
||||||
|
*
|
||||||
|
* Low-level switches for debugging and for unfinished features, stored in the `flags` settings blob.
|
||||||
|
* They are readable without authentication — the frontend needs `experimental` before anyone has
|
||||||
|
* logged in — so a flag must never carry anything sensitive.
|
||||||
|
*/
|
||||||
|
class Flags {
|
||||||
|
/**
|
||||||
|
* Every flag, with anything missing from the stored blob reported as off
|
||||||
|
*/
|
||||||
|
getFlags(): Record<Flag, boolean> {
|
||||||
|
const flags = WIKI.config.flags ?? {}
|
||||||
|
return Object.fromEntries(FLAG_KEYS.map((key) => [key, flags[key] === true])) as Record<
|
||||||
|
Flag,
|
||||||
|
boolean
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a single flag is on.
|
||||||
|
*
|
||||||
|
* Reads the config directly on every call, so flipping a flag takes effect immediately — including
|
||||||
|
* on the other instances of a cluster, which reload their config when this one saves.
|
||||||
|
*/
|
||||||
|
isEnabled(flag: Flag): boolean {
|
||||||
|
return WIKI.config.flags?.[flag] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the flags this model owns, dropping anything else a client sends
|
||||||
|
*/
|
||||||
|
pickFlags(body: Record<string, any>): Partial<Record<Flag, boolean>> {
|
||||||
|
const patch: Partial<Record<Flag, boolean>> = {}
|
||||||
|
for (const key of FLAG_KEYS) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
patch[key] = body[key] === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a patch of flags, leaving the ones it does not mention alone
|
||||||
|
*
|
||||||
|
* @returns Whether the flags were saved
|
||||||
|
*/
|
||||||
|
async updateFlags(patch: Partial<Record<Flag, boolean>>): Promise<boolean> {
|
||||||
|
const previous = WIKI.config.flags
|
||||||
|
WIKI.config.flags = { ...previous, ...patch }
|
||||||
|
|
||||||
|
if (!(await WIKI.configSvc.saveToDb(['flags']))) {
|
||||||
|
WIKI.config.flags = previous
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
WIKI.logger.info(`System flag ${key} is now ${value ? 'enabled' : 'disabled'}.`)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an authentication detail, but only while the auth debug flag is on.
|
||||||
|
*
|
||||||
|
* At info level rather than debug, because the default log level is info: sending these to debug
|
||||||
|
* would mean turning the flag on and seeing nothing.
|
||||||
|
*/
|
||||||
|
authDebug(message: string): void {
|
||||||
|
if (this.isEnabled('authDebug')) {
|
||||||
|
WIKI.logger.info(`[AUTH] ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flags = new Flags()
|
||||||
@ -0,0 +1,307 @@
|
|||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
import { hooks as hooksTable } from '../db/schema.ts'
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export const HOOK_EVENTS = [
|
||||||
|
'page:create',
|
||||||
|
'page:edit',
|
||||||
|
'page:rename',
|
||||||
|
'page:delete',
|
||||||
|
'asset:upload',
|
||||||
|
'asset:edit',
|
||||||
|
'asset:rename',
|
||||||
|
'asset:delete',
|
||||||
|
'comment:new',
|
||||||
|
'comment:edit',
|
||||||
|
'comment:delete',
|
||||||
|
'user:join',
|
||||||
|
'user:login',
|
||||||
|
'user:logout'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
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: `user:logout` looks like it belongs
|
||||||
|
* here, but there is no logout route yet. Add an event here when you add its `emit()` call.
|
||||||
|
*/
|
||||||
|
export const EMITTED_EVENTS: HookEvent[] = ['user:join', 'user:login']
|
||||||
|
|
||||||
|
/** A webhook as exposed by the API. */
|
||||||
|
export interface Hook {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
events: string[]
|
||||||
|
url: string
|
||||||
|
includeMetadata: boolean
|
||||||
|
includeContent: boolean
|
||||||
|
acceptUntrusted: boolean
|
||||||
|
authHeader: string | null
|
||||||
|
state: 'pending' | 'success' | 'error'
|
||||||
|
lastErrorMessage: string | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a remote endpoint has to answer before the delivery counts as failed. */
|
||||||
|
const DELIVERY_TIMEOUT = 15000
|
||||||
|
|
||||||
|
const hookSelection = {
|
||||||
|
id: hooksTable.id,
|
||||||
|
name: hooksTable.name,
|
||||||
|
events: hooksTable.events,
|
||||||
|
url: hooksTable.url,
|
||||||
|
includeMetadata: hooksTable.includeMetadata,
|
||||||
|
includeContent: hooksTable.includeContent,
|
||||||
|
acceptUntrusted: hooksTable.acceptUntrusted,
|
||||||
|
authHeader: hooksTable.authHeader,
|
||||||
|
state: hooksTable.state,
|
||||||
|
lastErrorMessage: hooksTable.lastErrorMessage,
|
||||||
|
createdAt: hooksTable.createdAt,
|
||||||
|
updatedAt: hooksTable.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a JSON body, with control over certificate validation.
|
||||||
|
*
|
||||||
|
* `node:https` rather than `fetch`: a webhook may legitimately point at an endpoint with a
|
||||||
|
* self-signed certificate, and per-request TLS options are not expressible through fetch.
|
||||||
|
*/
|
||||||
|
function postJson(
|
||||||
|
url: string,
|
||||||
|
body: string,
|
||||||
|
{ authHeader, acceptUntrusted }: { authHeader?: string | null; acceptUntrusted: boolean }
|
||||||
|
): Promise<{ statusCode: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let target: URL
|
||||||
|
try {
|
||||||
|
target = new URL(url)
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`"${url}" is not a valid URL.`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const transport = target.protocol === 'http:' ? http : https
|
||||||
|
|
||||||
|
const req = transport.request(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': Buffer.byteLength(body),
|
||||||
|
'user-agent': `Wiki.js/${WIKI.version}`,
|
||||||
|
...(authHeader ? { authorization: authHeader } : {})
|
||||||
|
},
|
||||||
|
timeout: DELIVERY_TIMEOUT,
|
||||||
|
...(target.protocol === 'https:' && acceptUntrusted ? { rejectUnauthorized: false } : {})
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
// -> The body is irrelevant, but it has to be drained for the socket to be released
|
||||||
|
res.resume()
|
||||||
|
res.on('end', () => resolve({ statusCode: res.statusCode ?? 0 }))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy(new Error(`The endpoint did not respond within ${DELIVERY_TIMEOUT / 1000}s.`))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks model
|
||||||
|
*
|
||||||
|
* Webhooks POST a JSON body to a remote endpoint when something happens. Delivery goes through the
|
||||||
|
* scheduler rather than the request that triggered it: a slow or broken endpoint must not delay a
|
||||||
|
* user's action, and the scheduler already provides retries and a place to see failures.
|
||||||
|
*/
|
||||||
|
class Hooks {
|
||||||
|
/**
|
||||||
|
* Every webhook, newest first
|
||||||
|
*/
|
||||||
|
async getHooks(): Promise<Hook[]> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select(hookSelection)
|
||||||
|
.from(hooksTable)
|
||||||
|
.orderBy(desc(hooksTable.createdAt))
|
||||||
|
return results as Hook[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single webhook, or null if there is no such webhook
|
||||||
|
*/
|
||||||
|
async getHookById(id: string): Promise<Hook | null> {
|
||||||
|
const results = await WIKI.db
|
||||||
|
.select(hookSelection)
|
||||||
|
.from(hooksTable)
|
||||||
|
.where(eq(hooksTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
return (results[0] as Hook) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a webhook. It starts out pending: no event has reached it yet.
|
||||||
|
*
|
||||||
|
* @returns The new webhook's ID
|
||||||
|
*/
|
||||||
|
async createHook(values: {
|
||||||
|
name: string
|
||||||
|
events: string[]
|
||||||
|
url: string
|
||||||
|
includeMetadata?: boolean
|
||||||
|
includeContent?: boolean
|
||||||
|
acceptUntrusted?: boolean
|
||||||
|
authHeader?: string
|
||||||
|
}): Promise<string> {
|
||||||
|
const result = await WIKI.db
|
||||||
|
.insert(hooksTable)
|
||||||
|
.values({
|
||||||
|
name: values.name,
|
||||||
|
events: values.events,
|
||||||
|
url: values.url,
|
||||||
|
includeMetadata: values.includeMetadata ?? true,
|
||||||
|
includeContent: values.includeContent ?? false,
|
||||||
|
acceptUntrusted: values.acceptUntrusted ?? false,
|
||||||
|
authHeader: values.authHeader ?? null,
|
||||||
|
state: 'pending'
|
||||||
|
})
|
||||||
|
.returning({ id: hooksTable.id })
|
||||||
|
return result[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a webhook.
|
||||||
|
*
|
||||||
|
* Changing where or what it sends resets the state to pending: the previous outcome says nothing
|
||||||
|
* about the new configuration.
|
||||||
|
*
|
||||||
|
* @returns Whether a webhook was updated
|
||||||
|
*/
|
||||||
|
async updateHook(id: string, patch: Record<string, any>): Promise<boolean> {
|
||||||
|
const values: Record<string, any> = { ...patch, updatedAt: sql`now()` }
|
||||||
|
if (patch.url !== undefined || patch.events !== undefined || patch.authHeader !== undefined) {
|
||||||
|
values.state = 'pending'
|
||||||
|
values.lastErrorMessage = null
|
||||||
|
}
|
||||||
|
const result = await WIKI.db.update(hooksTable).set(values).where(eq(hooksTable.id, id))
|
||||||
|
return (result.rowCount ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a webhook
|
||||||
|
*
|
||||||
|
* @returns Whether a webhook was deleted
|
||||||
|
*/
|
||||||
|
async deleteHook(id: string): Promise<boolean> {
|
||||||
|
const result = await WIKI.db.delete(hooksTable).where(eq(hooksTable.id, id))
|
||||||
|
return (result.rowCount ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a delivery for every webhook subscribed to an event.
|
||||||
|
*
|
||||||
|
* Safe to call from anywhere, including request handlers: it only writes jobs, and it never throws
|
||||||
|
* — a webhook problem must not fail the action that triggered it.
|
||||||
|
*
|
||||||
|
* @param data Event-specific payload. `metadata` and `content` are stripped per webhook, according
|
||||||
|
* to what each one asked for.
|
||||||
|
* @returns How many deliveries were queued
|
||||||
|
*/
|
||||||
|
async emit(event: HookEvent, data: Record<string, any> = {}): Promise<number> {
|
||||||
|
try {
|
||||||
|
const subscribed = await WIKI.db
|
||||||
|
.select({
|
||||||
|
id: hooksTable.id,
|
||||||
|
includeMetadata: hooksTable.includeMetadata,
|
||||||
|
includeContent: hooksTable.includeContent
|
||||||
|
})
|
||||||
|
.from(hooksTable)
|
||||||
|
.where(sql`${event} = ANY(${hooksTable.events})`)
|
||||||
|
|
||||||
|
let queued = 0
|
||||||
|
for (const hook of subscribed) {
|
||||||
|
const { metadata, content, ...rest } = data
|
||||||
|
const payload = {
|
||||||
|
...rest,
|
||||||
|
...(hook.includeMetadata && metadata !== undefined ? { metadata } : {}),
|
||||||
|
...(hook.includeContent && content !== undefined ? { content } : {})
|
||||||
|
}
|
||||||
|
const added = await WIKI.scheduler.addJob({
|
||||||
|
task: 'dispatchWebhook',
|
||||||
|
payload: { hookId: hook.id, event, data: payload }
|
||||||
|
})
|
||||||
|
if (added?.id) {
|
||||||
|
queued++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queued
|
||||||
|
} catch (err: any) {
|
||||||
|
WIKI.logger.warn(`Failed to queue webhook deliveries for ${event}: ${err.message}`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver one event to one webhook, recording the outcome on the webhook.
|
||||||
|
*
|
||||||
|
* Called by the `dispatchWebhook` task. Throws on failure so that the scheduler retries it.
|
||||||
|
*/
|
||||||
|
async deliver({
|
||||||
|
hookId,
|
||||||
|
event,
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
hookId: string
|
||||||
|
event: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}): Promise<void> {
|
||||||
|
const hook = await this.getHookById(hookId)
|
||||||
|
if (!hook) {
|
||||||
|
// -> Deleted between queueing and delivery; nothing to do and nothing to retry
|
||||||
|
WIKI.logger.info(`Webhook ${hookId} no longer exists, skipping delivery of ${event}.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
event,
|
||||||
|
sentAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
|
||||||
|
instance: WIKI.INSTANCE_ID,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { statusCode } = await postJson(hook.url, body, {
|
||||||
|
authHeader: hook.authHeader,
|
||||||
|
acceptUntrusted: hook.acceptUntrusted
|
||||||
|
})
|
||||||
|
if (statusCode < 200 || statusCode > 299) {
|
||||||
|
throw new Error(`The endpoint answered with HTTP ${statusCode}.`)
|
||||||
|
}
|
||||||
|
await WIKI.db
|
||||||
|
.update(hooksTable)
|
||||||
|
.set({ state: 'success', lastErrorMessage: null })
|
||||||
|
.where(eq(hooksTable.id, hook.id))
|
||||||
|
WIKI.logger.debug(`Delivered ${event} to webhook ${hook.name} [ OK ]`)
|
||||||
|
} catch (err: any) {
|
||||||
|
await WIKI.db
|
||||||
|
.update(hooksTable)
|
||||||
|
.set({ state: 'error', lastErrorMessage: err.message })
|
||||||
|
.where(eq(hooksTable.id, hook.id))
|
||||||
|
WIKI.logger.warn(`Failed to deliver ${event} to webhook ${hook.name}: ${err.message}`)
|
||||||
|
// -> Rethrown so the job fails and the scheduler retries with its usual backoff
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hooks = new Hooks()
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale to PostgreSQL text search dictionary, for the languages postgres ships a snowball stemmer
|
||||||
|
* for. Anything not listed here falls back to `simple`, which indexes words without stemming — still
|
||||||
|
* searchable, just without matching plurals and conjugations.
|
||||||
|
*
|
||||||
|
* An operator can override or extend this from the admin area, which is what `dictOverrides` is for.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DICTIONARIES: Record<string, string> = {
|
||||||
|
ar: 'arabic',
|
||||||
|
ca: 'catalan',
|
||||||
|
da: 'danish',
|
||||||
|
de: 'german',
|
||||||
|
el: 'greek',
|
||||||
|
en: 'english',
|
||||||
|
es: 'spanish',
|
||||||
|
et: 'estonian',
|
||||||
|
eu: 'basque',
|
||||||
|
fi: 'finnish',
|
||||||
|
fr: 'french',
|
||||||
|
ga: 'irish',
|
||||||
|
hi: 'hindi',
|
||||||
|
hu: 'hungarian',
|
||||||
|
hy: 'armenian',
|
||||||
|
id: 'indonesian',
|
||||||
|
it: 'italian',
|
||||||
|
lt: 'lithuanian',
|
||||||
|
ne: 'nepali',
|
||||||
|
nl: 'dutch',
|
||||||
|
no: 'norwegian',
|
||||||
|
pt: 'portuguese',
|
||||||
|
ro: 'romanian',
|
||||||
|
ru: 'russian',
|
||||||
|
sr: 'serbian',
|
||||||
|
sv: 'swedish',
|
||||||
|
ta: 'tamil',
|
||||||
|
tr: 'turkish',
|
||||||
|
yi: 'yiddish'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dictionary used when a locale has no mapping, or when its mapping is not installed. */
|
||||||
|
export const FALLBACK_DICTIONARY = 'simple'
|
||||||
|
|
||||||
|
export interface SearchConfig {
|
||||||
|
termHighlighting: boolean
|
||||||
|
dictOverrides: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a rebuild did, per locale, so the caller can report something concrete. */
|
||||||
|
export interface RebuildResult {
|
||||||
|
pages: number
|
||||||
|
locales: { locale: string; dictionary: string; pages: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search model
|
||||||
|
*
|
||||||
|
* Search is postgres full-text: every page carries a `ts` tsvector, indexed with GIN. Which
|
||||||
|
* dictionary builds that vector depends on the page's locale, which is why the mapping is
|
||||||
|
* configurable — using the wrong stemmer for a language quietly degrades results rather than
|
||||||
|
* failing.
|
||||||
|
*/
|
||||||
|
class Search {
|
||||||
|
/**
|
||||||
|
* The search configuration, with the shape the API and the admin area expect
|
||||||
|
*/
|
||||||
|
getConfig(): SearchConfig {
|
||||||
|
return {
|
||||||
|
termHighlighting: WIKI.config.search?.termHighlighting === true,
|
||||||
|
dictOverrides: (WIKI.config.search?.dictOverrides ?? {}) as Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text search configurations this postgres actually has, e.g. `english`, `simple`.
|
||||||
|
*
|
||||||
|
* Used to validate what an operator maps a locale to: a name postgres does not know would make
|
||||||
|
* every `to_tsvector` call fail at rebuild time, long after the setting was saved.
|
||||||
|
*/
|
||||||
|
async getAvailableDictionaries(): Promise<string[]> {
|
||||||
|
const rows = await WIKI.db.execute(sql`SELECT cfgname FROM pg_ts_config ORDER BY cfgname`)
|
||||||
|
return (rows.rows ?? rows).map((r: any) => r.cfgname as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dictionary to index a locale with, preferring the operator's override
|
||||||
|
*
|
||||||
|
* @param available Dictionary names postgres knows; an unknown mapping degrades to the fallback
|
||||||
|
*/
|
||||||
|
dictionaryForLocale(locale: string, available: string[]): string {
|
||||||
|
const { dictOverrides } = this.getConfig()
|
||||||
|
// -> Locales can be regional (`en-US`), while dictionaries are per language
|
||||||
|
const language = locale.split(/[-_]/)[0] ?? locale
|
||||||
|
const wanted =
|
||||||
|
dictOverrides[locale] ?? dictOverrides[language] ?? DEFAULT_DICTIONARIES[language]
|
||||||
|
if (wanted && available.includes(wanted)) {
|
||||||
|
return wanted
|
||||||
|
}
|
||||||
|
if (wanted) {
|
||||||
|
WIKI.logger.warn(
|
||||||
|
`Text search dictionary "${wanted}" for locale ${locale} is not installed — falling back to ${FALLBACK_DICTIONARY}.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return FALLBACK_DICTIONARY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute the search vector of every page.
|
||||||
|
*
|
||||||
|
* Grouped by locale, since the dictionary is chosen per locale. Title and description are weighted
|
||||||
|
* above the body so that a page whose title matches outranks one that merely mentions the term.
|
||||||
|
*
|
||||||
|
* Runs over every page rather than only searchable ones: whether a page shows up in results is
|
||||||
|
* decided at query time by `isSearchableComputed`, and keeping the vector current means flipping a
|
||||||
|
* page back to searchable needs no reindex.
|
||||||
|
*/
|
||||||
|
async rebuildIndex(): Promise<RebuildResult> {
|
||||||
|
const available = await this.getAvailableDictionaries()
|
||||||
|
const localeRows = await WIKI.db.execute(
|
||||||
|
sql`SELECT DISTINCT locale::text AS locale FROM pages ORDER BY locale`
|
||||||
|
)
|
||||||
|
const locales = ((localeRows.rows ?? localeRows) as any[]).map((r) => r.locale as string)
|
||||||
|
|
||||||
|
WIKI.logger.info(`Rebuilding the search index for ${locales.length} locale(s)...`)
|
||||||
|
const result: RebuildResult = { pages: 0, locales: [] }
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
const dictionary = this.dictionaryForLocale(locale, available)
|
||||||
|
// -> The dictionary name is an identifier in `to_tsvector`, and it is only ever one of the
|
||||||
|
// names postgres itself reported, so it cannot carry anything unexpected
|
||||||
|
const updated = await WIKI.db.execute(sql`
|
||||||
|
UPDATE pages SET ts =
|
||||||
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(description, '')), 'B') ||
|
||||||
|
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce("searchContent", '')), 'C')
|
||||||
|
WHERE locale::text = ${locale}
|
||||||
|
`)
|
||||||
|
const pages = updated.rowCount ?? 0
|
||||||
|
result.pages += pages
|
||||||
|
result.locales.push({ locale, dictionary, pages })
|
||||||
|
WIKI.logger.info(
|
||||||
|
`Reindexed ${pages} page(s) in ${locale} using the ${dictionary} dictionary.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WIKI.logger.info(`Search index rebuild completed: ${result.pages} page(s) [ OK ]`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const search = new Search()
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
import { CORS_MODES, parseCspDirectives } from '../helpers/security.ts'
|
||||||
|
|
||||||
|
/** Fields stored in the `security` settings blob. */
|
||||||
|
export const SECURITY_FIELDS = [
|
||||||
|
'corsConfig',
|
||||||
|
'corsMode',
|
||||||
|
'cspDirectives',
|
||||||
|
'disallowFloc',
|
||||||
|
'disallowIframe',
|
||||||
|
'disallowOpenRedirect',
|
||||||
|
'enforceCsp',
|
||||||
|
'enforceHsts',
|
||||||
|
'enforceSameOriginReferrerPolicy',
|
||||||
|
'forceAssetDownload',
|
||||||
|
'hstsDuration',
|
||||||
|
'trustProxy',
|
||||||
|
'uploadMaxFileSize',
|
||||||
|
'uploadMaxFiles',
|
||||||
|
'uploadScanSVG'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JWT fields the admin area shows, mapped onto the `auth` settings they really live in.
|
||||||
|
*
|
||||||
|
* The `security` blob used to carry copies of these under the 2.x names, which nothing read — so the
|
||||||
|
* view was editing values with no effect. These are the keys the running server uses.
|
||||||
|
*/
|
||||||
|
export const AUTH_FIELD_MAP = {
|
||||||
|
authJwtAudience: 'audience',
|
||||||
|
authJwtExpiration: 'tokenExpiration',
|
||||||
|
authJwtRenewablePeriod: 'tokenRenewal'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** A duration as the admin area writes it: `30m`, `14d`, `1y`. */
|
||||||
|
const DURATION_PATTERN = /^\d+[smhdwy]$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security model
|
||||||
|
*
|
||||||
|
* One flat surface for the admin area's security view, even though the values are stored in two
|
||||||
|
* settings blobs. Most of them are read when the HTTP server starts — see the `Security` section of
|
||||||
|
* `index.ts` — so saving them here takes effect on the next restart.
|
||||||
|
*/
|
||||||
|
class Security {
|
||||||
|
/**
|
||||||
|
* The security configuration as the admin area expects it
|
||||||
|
*/
|
||||||
|
getConfig(): Record<string, any> {
|
||||||
|
const security = WIKI.config.security ?? {}
|
||||||
|
const config: Record<string, any> = {}
|
||||||
|
for (const field of SECURITY_FIELDS) {
|
||||||
|
config[field] = security[field]
|
||||||
|
}
|
||||||
|
for (const [field, authKey] of Object.entries(AUTH_FIELD_MAP)) {
|
||||||
|
config[field] = WIKI.config.auth?.[authKey]
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the fields this model owns, dropping anything else a client sends
|
||||||
|
*/
|
||||||
|
pickFields(body: Record<string, any>): Record<string, any> {
|
||||||
|
const patch: Record<string, any> = {}
|
||||||
|
for (const field of [...SECURITY_FIELDS, ...Object.keys(AUTH_FIELD_MAP)]) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
patch[field] = body[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a patch against the settings it will end up merged with.
|
||||||
|
*
|
||||||
|
* Merged rather than in isolation, because these fields constrain each other: turning CSP on with
|
||||||
|
* no directives, or picking the hostname whitelist mode without hostnames, would store a setting
|
||||||
|
* that quietly does nothing.
|
||||||
|
*
|
||||||
|
* @returns The reason it is invalid, or null when it is fine
|
||||||
|
*/
|
||||||
|
validate(patch: Record<string, any>): string | null {
|
||||||
|
const merged = { ...this.getConfig(), ...patch }
|
||||||
|
|
||||||
|
if (!CORS_MODES.includes(merged.corsMode)) {
|
||||||
|
return `"${merged.corsMode}" is not a valid CORS mode.`
|
||||||
|
}
|
||||||
|
if (merged.corsMode === 'REGEX') {
|
||||||
|
try {
|
||||||
|
new RegExp(merged.corsConfig ?? '')
|
||||||
|
} catch (err: any) {
|
||||||
|
return `The CORS regex pattern is invalid: ${err.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.corsMode === 'HOSTNAMES') {
|
||||||
|
const hostnames = (merged.corsConfig ?? '')
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry: string) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (hostnames.length < 1) {
|
||||||
|
return 'The hostname whitelist mode needs at least one hostname.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.enforceCsp) {
|
||||||
|
if (Object.keys(parseCspDirectives(merged.cspDirectives ?? '')).length < 1) {
|
||||||
|
return 'Enforcing a Content-Security-Policy needs at least one directive.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.enforceHsts && !(merged.hstsDuration > 0)) {
|
||||||
|
return 'Enforcing HSTS needs a duration greater than zero.'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [field, label] of [
|
||||||
|
['authJwtExpiration', 'token expiration'],
|
||||||
|
['authJwtRenewablePeriod', 'token renewal period']
|
||||||
|
] as const) {
|
||||||
|
if (!DURATION_PATTERN.test(merged[field] ?? '')) {
|
||||||
|
return `The ${label} must be a duration such as 30m, 12h or 14d.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!merged.authJwtAudience || `${merged.authJwtAudience}`.trim().length < 1) {
|
||||||
|
return 'The JWT audience cannot be empty.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a validated patch, splitting it across the two settings blobs it belongs to.
|
||||||
|
*
|
||||||
|
* Both are written in one go and rolled back together, so a failure cannot leave the JWT settings
|
||||||
|
* updated while the rest is not.
|
||||||
|
*
|
||||||
|
* @returns Whether the settings were saved
|
||||||
|
*/
|
||||||
|
async updateConfig(patch: Record<string, any>): Promise<boolean> {
|
||||||
|
const previousSecurity = WIKI.config.security
|
||||||
|
const previousAuth = WIKI.config.auth
|
||||||
|
const keys: string[] = []
|
||||||
|
|
||||||
|
const securityPatch: Record<string, any> = {}
|
||||||
|
const authPatch: Record<string, any> = {}
|
||||||
|
for (const [field, value] of Object.entries(patch)) {
|
||||||
|
const authKey = AUTH_FIELD_MAP[field as keyof typeof AUTH_FIELD_MAP]
|
||||||
|
if (authKey) {
|
||||||
|
authPatch[authKey] = typeof value === 'string' ? value.trim() : value
|
||||||
|
} else {
|
||||||
|
securityPatch[field] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(securityPatch).length > 0) {
|
||||||
|
WIKI.config.security = { ...previousSecurity, ...securityPatch }
|
||||||
|
keys.push('security')
|
||||||
|
}
|
||||||
|
if (Object.keys(authPatch).length > 0) {
|
||||||
|
WIKI.config.auth = { ...previousAuth, ...authPatch }
|
||||||
|
keys.push('auth')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await WIKI.configSvc.saveToDb(keys))) {
|
||||||
|
WIKI.config.security = previousSecurity
|
||||||
|
WIKI.config.auth = previousAuth
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const security = new Security()
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
key: git
|
||||||
|
title: Git
|
||||||
|
description: >-
|
||||||
|
Distributed version control system. Required for the Git storage module to synchronize content with
|
||||||
|
a remote repository.
|
||||||
|
website: 'https://git-scm.com'
|
||||||
|
# Detection: a `git` executable somewhere on PATH
|
||||||
|
detect:
|
||||||
|
type: command
|
||||||
|
value: git
|
||||||
|
# Installed with the operating system's package manager, not from here
|
||||||
|
isInstallable: false
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
key: pandoc
|
||||||
|
title: Pandoc
|
||||||
|
description: >-
|
||||||
|
Converts between markup formats. Required to import content from other wikis and formats such as
|
||||||
|
MediaWiki, AsciiDoc, Textile or DocBook.
|
||||||
|
website: 'https://pandoc.org'
|
||||||
|
detect:
|
||||||
|
type: command
|
||||||
|
value: pandoc
|
||||||
|
isInstallable: false
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
key: puppeteer
|
||||||
|
title: Puppeteer
|
||||||
|
description: >-
|
||||||
|
Headless Chromium browser. Required to export pages as PDF and to render content elements on the
|
||||||
|
server, such as Mermaid or PlantUML diagrams.
|
||||||
|
website: 'https://pptr.dev'
|
||||||
|
detect:
|
||||||
|
type: module
|
||||||
|
value: puppeteer
|
||||||
|
architectures:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
isInstallable: false
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
key: sharp
|
||||||
|
title: Sharp
|
||||||
|
description: >-
|
||||||
|
Processes and transforms images. Required to generate thumbnails of uploaded images and to resize
|
||||||
|
site assets such as logos.
|
||||||
|
website: 'https://sharp.pixelplumbing.com'
|
||||||
|
# Detection: an optional dependency, so resolvable from the backend's node_modules when present
|
||||||
|
detect:
|
||||||
|
type: module
|
||||||
|
value: sharp
|
||||||
|
# Prebuilt binaries are published for these architectures only
|
||||||
|
architectures:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
isInstallable: false
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Deliver one event to one webhook.
|
||||||
|
*
|
||||||
|
* Queued by `models/hooks.ts` → `emit()`, one job per subscribed webhook, so that a slow endpoint
|
||||||
|
* delays nothing else and a failing one is retried with the scheduler's backoff.
|
||||||
|
*/
|
||||||
|
export async function task(payload: {
|
||||||
|
hookId: string
|
||||||
|
event: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}): Promise<void> {
|
||||||
|
await WIKI.models.hooks.deliver(payload)
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Recompute the search vector of every page.
|
||||||
|
*
|
||||||
|
* Queued from the admin area's search view, and safe to run at any time: it only rewrites `pages.ts`
|
||||||
|
* from the content already stored on each page.
|
||||||
|
*/
|
||||||
|
export async function task(): Promise<void> {
|
||||||
|
await WIKI.models.search.rebuildIndex()
|
||||||
|
}
|
||||||
Loading…
Reference in new issue