refactor: wire remaining admin views

pull/8104/head
NGPixel 2 months ago
parent 45b5bd5cdc
commit d1c41b4111
No known key found for this signature in database

@ -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

@ -192,7 +192,10 @@ async function routes(app: FastifyInstance) {
if (err.message.startsWith('ERR_')) { if (err.message.startsWith('ERR_')) {
return reply.badRequest(err.message) return reply.badRequest(err.message)
} else { } else {
WIKI.logger.info(err) // TODO: change to debug once stable // -> An unexpected failure, reported to the client as a generic one. The detail is behind
// the authDebug flag rather than logged on every failed login.
WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Login failed unexpectedly: ${err.message}`)
return reply.badRequest('ERR_LOGIN_FAILED') return reply.badRequest('ERR_LOGIN_FAILED')
} }
} }
@ -266,14 +269,306 @@ async function routes(app: FastifyInstance) {
} }
} catch (err: any) { } catch (err: any) {
if (err.message.startsWith('ERR_')) { if (err.message.startsWith('ERR_')) {
WIKI.models.flags.authDebug(`Password change from login rejected: ${err.message}`)
return reply.badRequest(err.message) return reply.badRequest(err.message)
} else { } else {
WIKI.logger.debug(err) WIKI.logger.debug(err)
WIKI.models.flags.authDebug(`Password change from login failed: ${err.message}`)
return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED') return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED')
} }
} }
} }
) )
/**
* LIST AUTHENTICATION MODULES
*/
app.get(
'/authentication/modules',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the authentication modules available on this server',
description:
'Read from `modules/authentication` at startup, so installing a module means dropping it on disk and restarting. Modules that declare themselves unavailable are not listed.',
tags: ['Authentication'],
response: {
200: {
description: 'List of authentication modules',
type: 'array',
items: { $ref: 'AuthModule#' }
}
}
}
},
async () => {
return WIKI.models.authentication.getModules()
}
)
/**
* LIST CONFIGURED STRATEGIES
*/
app.get(
'/authentication/strategies',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the configured authentication strategies',
description:
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. Configuration values include any secrets a module stores, hence the `manage:system` requirement.',
tags: ['Authentication'],
response: {
200: {
description: 'List of configured strategies',
type: 'array',
items: { $ref: 'AuthStrategy#' }
}
}
}
},
async () => {
return WIKI.models.authentication.getActiveStrategies()
}
)
/**
* GET CONFIGURED STRATEGY
*/
app.get<{ Params: { strategyId: string } }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get a single configured authentication strategy',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
response: {
200: { $ref: 'AuthStrategy#' }
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!strategy) {
return reply.notFound('Authentication strategy does not exist.')
}
return strategy
}
)
/**
* CREATE STRATEGY
*/
app.post<{ Body: Record<string, any> }>(
'/authentication/strategies',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Configure a new authentication strategy',
description:
'A module can be configured more than once, so that two instances of the same provider can coexist. A new strategy is not offered by any site until that site adds it to its login screen.',
tags: ['Authentication'],
body: {
allOf: [{ $ref: 'AuthStrategyInput#' }, { required: ['module'] }]
},
response: {
200: {
description: 'Strategy created successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid'
}
}
}
}
}
},
async (req, reply) => {
const mod = WIKI.models.authentication.getModule(req.body.module)
if (!mod) {
return reply.badRequest(`There is no authentication module named "${req.body.module}".`)
}
const invalid =
(await WIKI.models.authentication.validateStrategy({
module: req.body.module,
displayName: req.body.displayName,
isEnabled: req.body.isEnabled,
allowedEmailRegex: req.body.allowedEmailRegex,
autoEnrollGroups: req.body.autoEnrollGroups
})) ?? WIKI.models.authentication.validateConfig(req.body.module, req.body.config)
if (invalid) {
return reply.badRequest(invalid)
}
const id = await WIKI.models.authentication.createStrategy(req.body as any)
return {
ok: true,
message: 'Authentication strategy created successfully.',
id
}
}
)
/**
* UPDATE STRATEGY
*/
app.put<{ Params: { strategyId: string }; Body: Record<string, any> }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update an authentication strategy',
description:
'Accepts any subset of the fields, except `module`, which is fixed once a strategy exists. The strategies are reloaded on success, so a configuration change applies to the next login rather than after a restart.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
body: { $ref: 'AuthStrategyInput#' },
response: {
200: {
description: 'Strategy updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const current = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!current) {
return reply.notFound('Authentication strategy does not exist.')
}
if (req.body.module !== undefined && req.body.module !== current.module) {
return reply.badRequest('The module of an existing strategy cannot be changed.')
}
const patch: Record<string, any> = {}
for (const field of [
'displayName',
'isEnabled',
'registration',
'allowedEmailRegex',
'autoEnrollGroups',
'config'
] as const) {
if (req.body[field] !== undefined) {
patch[field] = req.body[field]
}
}
if (Object.keys(patch).length < 1) {
return reply.badRequest('No strategy fields provided to update.')
}
const invalid =
(await WIKI.models.authentication.validateStrategy({
id: current.id,
module: current.module,
...patch
})) ?? WIKI.models.authentication.validateConfig(current.module, patch.config)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.authentication.updateStrategy(req.params.strategyId, patch))) {
return reply.internalServerError('Failed to update the authentication strategy.')
}
return {
ok: true,
message: 'Authentication strategy updated successfully.'
}
}
)
/**
* DELETE STRATEGY
*/
app.delete<{ Params: { strategyId: string } }>(
'/authentication/strategies/:strategyId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Delete an authentication strategy',
description:
'Also removes it from every site’s login screen. The built-in local strategy cannot be deleted: every account stores its password under that strategy ID, so removing it would leave no way in.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: {
type: 'string',
format: 'uuid'
}
},
required: ['strategyId']
},
response: {
204: {
description: 'Strategy deleted successfully'
}
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
if (!strategy) {
return reply.notFound('Authentication strategy does not exist.')
}
if (strategy.id === WIKI.data.systemIds.localAuthId) {
return reply.conflict('The built-in local strategy cannot be deleted.')
}
await WIKI.models.authentication.deleteStrategy(req.params.strategyId)
return reply.code(204).send()
}
)
} }
export default routes 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

@ -5,17 +5,25 @@ import type { FastifyInstance } from 'fastify'
*/ */
async function routes(app: FastifyInstance) { async function routes(app: FastifyInstance) {
// Register schemas // Register schemas
await import('./schemas/apiKey.ts').then((m) => m.registerSchemas(app))
await import('./schemas/authentication.ts').then((m) => m.registerSchemas(app))
await import('./schemas/block.ts').then((m) => m.registerSchemas(app)) await import('./schemas/block.ts').then((m) => m.registerSchemas(app))
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
await import('./schemas/flags.ts').then((m) => m.registerSchemas(app))
await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/hook.ts').then((m) => m.registerSchemas(app))
await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))
await import('./schemas/security.ts').then((m) => m.registerSchemas(app))
await import('./schemas/site.ts').then((m) => m.registerSchemas(app)) await import('./schemas/site.ts').then((m) => m.registerSchemas(app))
await import('./schemas/user.ts').then((m) => m.registerSchemas(app)) await import('./schemas/user.ts').then((m) => m.registerSchemas(app))
// Register routes // Register routes
app.register(import('./apiKeys.ts'), { prefix: '/api-keys' })
app.register(import('./authentication.ts')) app.register(import('./authentication.ts'))
app.register(import('./blocks.ts')) app.register(import('./blocks.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' }) app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./hooks.ts'), { prefix: '/hooks' })
app.register(import('./locales.ts'), { prefix: '/locales' }) app.register(import('./locales.ts'), { prefix: '/locales' })
app.register(import('./mail.ts'), { prefix: '/mail' }) app.register(import('./mail.ts'), { prefix: '/mail' })
app.register(import('./pages.ts')) app.register(import('./pages.ts'))

@ -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`.'
}
}
})
}

@ -56,6 +56,10 @@ async function routes(app: FastifyInstance) {
isMailConfigured: { isMailConfigured: {
type: 'boolean' type: 'boolean'
}, },
isApiEnabled: {
type: 'boolean',
description: 'Whether API keys are accepted.'
},
isMetricsEnabled: { isMetricsEnabled: {
type: 'boolean', type: 'boolean',
description: 'Whether the Prometheus metrics endpoint is turned on.' description: 'Whether the Prometheus metrics endpoint is turned on.'
@ -117,6 +121,7 @@ async function routes(app: FastifyInstance) {
groupsTotal: await WIKI.db.$count(groupsTable), groupsTotal: await WIKI.db.$count(groupsTable),
hostname: os.hostname(), hostname: os.hostname(),
httpPort: 0, httpPort: 0,
isApiEnabled: WIKI.config.api.isEnabled === true,
isMailConfigured: WIKI.config?.mail?.host?.length > 2, isMailConfigured: WIKI.config?.mail?.host?.length > 2,
isMetricsEnabled: WIKI.config.metrics.isEnabled === true, isMetricsEnabled: WIKI.config.metrics.isEnabled === true,
isSchedulerHealthy: await WIKI.models.jobs.isHealthy(), isSchedulerHealthy: await WIKI.models.jobs.isHealthy(),
@ -147,20 +152,175 @@ async function routes(app: FastifyInstance) {
{ {
schema: { schema: {
summary: 'System Flags', summary: 'System Flags',
description:
'Readable without authentication: the frontend needs `experimental` before anyone has logged in, to know which unfinished features to reveal. A flag must therefore never carry anything sensitive.',
tags: ['System'],
response: {
200: { $ref: 'SystemFlags#' }
}
}
},
async () => {
return WIKI.models.flags.getFlags()
}
)
/**
* UPDATE SYSTEM FLAGS
*/
app.put<{ Body: Record<string, any> }>(
'/flags',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the system flags',
description:
'Accepts any subset of the flags. All of them take effect immediately, without a restart: `authDebug` and `sqlLog` write to the server log at info level, and `experimental` is picked up by the frontend on its next load.',
tags: ['System'], tags: ['System'],
body: { $ref: 'SystemFlags#' },
response: { response: {
200: { 200: {
description: 'System Flags', description: 'System flags updated successfully',
type: 'object', type: 'object',
properties: { properties: {
experimental: { ok: {
type: 'boolean' type: 'boolean'
}, },
authDebug: { message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.flags.pickFlags(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No system flags provided to update.')
}
if (!(await WIKI.models.flags.updateFlags(patch))) {
return reply.internalServerError('Failed to save the system flags.')
}
return {
ok: true,
message: 'System flags updated successfully.'
}
}
)
/**
* GET SECURITY CONFIGURATION
*/
app.get(
'/security',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the security configuration',
description:
'The JWT fields come from the `auth` settings, which are the ones actually in force. Most of the rest is applied when the HTTP server starts, so changing it takes effect on the next restart.',
tags: ['System'],
response: {
200: { $ref: 'SecurityConfig#' }
}
}
},
async () => {
return WIKI.models.security.getConfig()
}
)
/**
* UPDATE SECURITY CONFIGURATION
*/
app.put<{ Body: Record<string, any> }>(
'/security',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the security configuration',
description:
'Accepts any subset of the fields. Changing the JWT audience invalidates every API key already issued, since a key carries the audience it was signed with. Header, CORS and proxy settings are read when the HTTP server starts and therefore apply after a restart.',
tags: ['System'],
body: { $ref: 'SecurityConfig#' },
response: {
200: {
description: 'Security configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean' type: 'boolean'
}, },
sqlLog: { message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.security.pickFields(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No security settings provided to update.')
}
const invalid = WIKI.models.security.validate(patch)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.security.updateConfig(patch))) {
return reply.internalServerError('Failed to save the security configuration.')
}
return {
ok: true,
message: 'Security configuration updated successfully.'
}
}
)
/**
* GET SEARCH CONFIGURATION
*/
app.get(
'/search',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the search configuration',
description:
'Search is postgres full-text. `availableDictionaries` lists the text search configurations this database has, which is what a locale may be mapped to.',
tags: ['System'],
response: {
200: {
description: 'Search configuration',
type: 'object',
properties: {
termHighlighting: {
type: 'boolean' type: 'boolean'
},
dictOverrides: {
type: 'object',
description:
'Locale code to postgres dictionary, e.g. `{ "en": "english" }`. Overrides the built-in mapping.',
additionalProperties: { type: 'string' }
},
availableDictionaries: {
type: 'array',
description: 'Dictionary names this postgres installation knows.',
items: { type: 'string' }
} }
} }
} }
@ -168,7 +328,320 @@ async function routes(app: FastifyInstance) {
} }
}, },
async () => { async () => {
return WIKI.config.flags return {
...WIKI.models.search.getConfig(),
availableDictionaries: await WIKI.models.search.getAvailableDictionaries()
}
}
)
/**
* UPDATE SEARCH CONFIGURATION
*/
app.put<{ Body: { termHighlighting?: boolean; dictOverrides?: Record<string, string> } }>(
'/search',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the search configuration',
description:
'Every dictionary named in `dictOverrides` must exist in this database, otherwise indexing would fail later, long after the setting was accepted. Changing a mapping affects pages the next time they are indexed — rebuild the index to apply it to existing content.',
tags: ['System'],
body: {
type: 'object',
properties: {
termHighlighting: {
type: 'boolean'
},
dictOverrides: {
type: 'object',
description: 'Locale code to postgres dictionary. Replaces the stored mapping.',
additionalProperties: { type: 'string' }
}
}
},
response: {
200: {
description: 'Search configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
if (req.body.termHighlighting === undefined && req.body.dictOverrides === undefined) {
return reply.badRequest('No search settings provided to update.')
}
if (req.body.dictOverrides) {
const available = await WIKI.models.search.getAvailableDictionaries()
for (const [locale, dictionary] of Object.entries(req.body.dictOverrides)) {
if (!/^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/.test(locale)) {
return reply.badRequest(`"${locale}" is not a valid locale code.`)
}
if (!available.includes(dictionary)) {
return reply.badRequest(
`"${dictionary}" is not a text search dictionary in this database.`
)
}
}
}
const previousConfig = WIKI.config.search
WIKI.config.search = {
...previousConfig,
...(req.body.termHighlighting !== undefined && {
termHighlighting: req.body.termHighlighting
}),
...(req.body.dictOverrides !== undefined && { dictOverrides: req.body.dictOverrides })
}
if (!(await WIKI.configSvc.saveToDb(['search']))) {
WIKI.config.search = previousConfig
return reply.internalServerError('Failed to save the search configuration.')
}
return {
ok: true,
message: 'Search configuration updated successfully.'
}
}
)
/**
* REBUILD SEARCH INDEX
*/
app.post(
'/search/rebuild',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Rebuild the search index',
description:
'Queues a job that recomputes the search vector of every page from its stored content, using the dictionary mapping in force. Runs in the background: the response only says the job was queued.',
tags: ['System'],
response: {
200: {
description: 'Rebuild queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'ID of the queued job, which the scheduler view lists.'
}
}
}
}
}
},
async (req, reply) => {
const added = await WIKI.scheduler.addJob({ task: 'rebuildSearchIndex' })
if (!added?.id) {
return reply.internalServerError('The scheduler could not queue the rebuild.')
}
return {
ok: true,
message: 'Search index rebuild queued successfully.',
id: added.id
}
}
)
/**
* LIST EXTENSIONS
*/
app.get(
'/extensions',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List optional extensions',
description:
'Third-party tooling that unlocks extra functionality, with whether each one is present on this system. Detection runs per request, so installing a tool shows up without a restart.',
tags: ['System'],
response: {
200: {
description: 'List of extensions',
type: 'array',
items: { $ref: 'Extension#' }
}
}
}
},
async () => {
return WIKI.models.extensions.getExtensions()
}
)
/**
* INSTALL EXTENSION
*/
app.post<{ Params: { extensionKey: string } }>(
'/extensions/:extensionKey/install',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Install an extension',
description:
'Only extensions flagged `isInstallable` can be installed from here. None currently are: Git and Pandoc come from the operating system, Sharp and Puppeteer are optional dependencies, so both are installed outside the application and this answers 409 pointing at the documentation.',
tags: ['System'],
params: {
type: 'object',
properties: {
extensionKey: {
type: 'string',
maxLength: 255
}
},
required: ['extensionKey']
},
response: {
200: {
description: 'Extension installed successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const definition = WIKI.models.extensions.getDefinition(req.params.extensionKey)
if (!definition) {
return reply.notFound('Extension does not exist.')
}
if (!WIKI.models.extensions.isCompatible(definition)) {
return reply.conflict('This extension is not compatible with this system.')
}
if (definition.isInstallable !== true) {
return reply.conflict(
`${definition.title} must be installed manually. See the documentation for instructions.`
)
}
// -> No extension declares itself installable yet; an installer belongs with the extension
// that needs it, next to its definition
return reply.notImplemented('Installing this extension is not implemented yet.')
}
)
/**
* GET API STATE
*/
app.get(
'/api',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the API state',
description:
'Whether API keys are accepted. While this is off, every request presenting a key is rejected, no matter how valid the key is.',
tags: ['System'],
response: {
200: {
description: 'API state',
type: 'object',
properties: {
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async () => {
return { isEnabled: WIKI.config.api.isEnabled === true }
}
)
/**
* SET API STATE
*/
app.put<{ Body: { isEnabled: boolean } }>(
'/api',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Turn the API on or off',
description:
'Turning it off stops every API key from authenticating, without revoking any of them. Session-authenticated requests, i.e. the admin area itself, are unaffected.',
tags: ['System'],
body: {
type: 'object',
required: ['isEnabled'],
properties: {
isEnabled: {
type: 'boolean'
}
}
},
response: {
200: {
description: 'API state updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
const previousConfig = WIKI.config.api
WIKI.config.api = { ...previousConfig, isEnabled: req.body.isEnabled }
if (!(await WIKI.configSvc.saveToDb(['api']))) {
WIKI.config.api = previousConfig
return reply.internalServerError('Failed to save the API state.')
}
return {
ok: true,
message: req.body.isEnabled ? 'API enabled successfully.' : 'API disabled successfully.',
isEnabled: req.body.isEnabled
}
} }
) )

@ -64,12 +64,9 @@ defaults:
uploadScanSVG: true uploadScanSVG: true
disallowIframe: true disallowIframe: true
uploadMaxFiles: 20 uploadMaxFiles: 20
authJwtAudience: 'urn:wiki.js'
authJwtExpiration: '30m'
uploadMaxFileSize: 10485760 uploadMaxFileSize: 10485760
forceAssetDownload: true forceAssetDownload: true
disallowOpenRedirect: true disallowOpenRedirect: true
authJwtRenewablePeriod: '14d'
enforceSameOriginReferrerPolicy: true enforceSameOriginReferrerPolicy: true
flags: flags:
experimental: false experimental: false

@ -9,19 +9,36 @@ import { parse } from 'pg-connection-string'
import semver from 'semver' import semver from 'semver'
import { relations } from '../db/relations.ts' import { relations } from '../db/relations.ts'
import { flags } from '../models/flags.ts'
import { createDeferred } from '../helpers/common.ts' import { createDeferred } from '../helpers/common.ts'
// import migrationSource from '../db/migrator-source.js' // import migrationSource from '../db/migrator-source.js'
// const migrateFromLegacy = require('../db/legacy') // const migrateFromLegacy = require('../db/legacy')
/**
* Query logger, consulted by Drizzle on every query.
*
* The decision is made per query rather than when the instance is built, so that the `sqlLog` system
* flag can be turned on in the admin area and take effect on the next query — a logger chosen at boot
* would need a restart.
*/
const queryLogger = {
logQuery(query: string, params: unknown[]): void {
if (!flags.isEnabled('sqlLog') && !WIKI.config.dev?.logQueries) {
return
}
WIKI.logger.info(`[SQL] ${query}${params.length > 0 ? ` -- ${JSON.stringify(params)}` : ''}`)
}
}
/** /**
* Build the Drizzle instance. * Build the Drizzle instance.
* *
* The two branches are spelled out rather than spreading a conditional `{ logger: true }` into a * `logger` is passed unconditionally rather than spread in from a conditional: a spread in the config
* single call: a spread in the config literal collapses the inferred relations to `EmptyRelations`, * literal collapses the inferred relations to `EmptyRelations`, which would untype the whole
* which would untype the whole `db.query.*` relational API. * `db.query.*` relational API.
*/ */
function createDb(client: Pool, logQueries: boolean) { function createDb(client: Pool) {
return logQueries ? drizzle({ client, relations, logger: true }) : drizzle({ client, relations }) return drizzle({ client, relations, logger: queryLogger })
} }
/** The Drizzle instance, as returned by `init()` and exposed as `WIKI.db`. */ /** The Drizzle instance, as returned by `init()` and exposed as `WIKI.db`. */
@ -117,7 +134,7 @@ export default {
options: `-c search_path=${WIKI.config.db.schema}` options: `-c search_path=${WIKI.config.db.schema}`
}) })
const db = createDb(this.pool, Boolean(WIKI.config.dev?.logQueries)) const db = createDb(this.pool)
// Connect // Connect
await this.connect(db) await this.connect(db)

@ -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

@ -36,7 +36,13 @@ const tsvector = customType({
export const apiKeys = pgTable('apiKeys', { export const apiKeys = pgTable('apiKeys', {
id: uuid().primaryKey().defaultRandom(), id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull(), name: varchar({ length: 255 }).notNull(),
key: text().notNull(), // -> Only the tail of the token, to tell keys apart in the admin list. The token itself is a
// signed JWT shown once at creation and never stored: it is a bearer credential, and
// verification needs the public key plus this row's state, not the token.
keyShort: varchar({ length: 8 }).notNull(),
// -> IDs of the groups whose permissions the key carries. Resolved on every request, so editing a
// group immediately affects the keys pointing at it.
groups: jsonb().notNull().default([]),
expiration: timestamp().notNull().defaultNow(), expiration: timestamp().notNull().defaultNow(),
isRevoked: boolean().notNull().default(false), isRevoked: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
@ -116,6 +122,29 @@ export const groups = pgTable('groups', {
updatedAt: timestamp().notNull().defaultNow() updatedAt: timestamp().notNull().defaultNow()
}) })
// HOOKS -------------------------------
export const hookStateEnum = pgEnum('hookState', ['pending', 'success', 'error'])
export const hooks = pgTable('hooks', {
id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull(),
// -> Event keys such as `page:create`, matched against what the server emits
events: text()
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
url: text().notNull(),
includeMetadata: boolean().notNull().default(true),
includeContent: boolean().notNull().default(false),
acceptUntrusted: boolean().notNull().default(false),
// -> Sent verbatim as the Authorization header, so it holds whatever secret the remote expects
authHeader: text(),
// -> Outcome of the most recent delivery, which is what the admin list shows
state: hookStateEnum().notNull().default('pending'),
lastErrorMessage: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
})
// JOB HISTORY ------------------------- // JOB HISTORY -------------------------
export const jobHistoryStateEnum = pgEnum('jobHistoryState', [ export const jobHistoryStateEnum = pgEnum('jobHistoryState', [
'active', 'active',

@ -112,6 +112,7 @@ export interface ModulePropDefinition {
enumDisplay?: string enumDisplay?: string
multiline?: boolean multiline?: boolean
sensitive?: boolean sensitive?: boolean
readOnly?: boolean
icon?: string icon?: string
order?: number order?: number
if?: unknown[] if?: unknown[]
@ -127,6 +128,8 @@ export interface ModuleProp {
enumDisplay: string enumDisplay: string
multiline: boolean multiline: boolean
sensitive: boolean sensitive: boolean
/** Shown but not editable — the module declares something this server cannot currently change. */
readOnly: boolean
icon: string icon: string
order: number order: number
if: unknown[] if: unknown[]
@ -149,6 +152,7 @@ export function parseModuleProps(
enumDisplay: def.enumDisplay || 'select', enumDisplay: def.enumDisplay || 'select',
multiline: def.multiline || false, multiline: def.multiline || false,
sensitive: def.sensitive || false, sensitive: def.sensitive || false,
readOnly: def.readOnly || false,
icon: def.icon || 'rename', icon: def.icon || 'rename',
order: def.order || 100, order: def.order || 100,
if: def.if ?? [] if: def.if ?? []

@ -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
}
}

@ -33,6 +33,7 @@ import configSvc from './core/config.ts'
import dbManager from './core/db.ts' import dbManager from './core/db.ts'
import logger from './core/logger.ts' import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts' import scheduler from './core/scheduler.ts'
import { corsOrigin, parseCspDirectives } from './helpers/security.ts'
const nanoid = customAlphabet('1234567890abcdef', 10) const nanoid = customAlphabet('1234567890abcdef', 10)
@ -145,6 +146,11 @@ async function postBoot() {
await WIKI.models.blocks.refreshFromDisk() await WIKI.models.blocks.refreshFromDisk()
await WIKI.models.blocks.syncAllSites() await WIKI.models.blocks.syncAllSites()
// -> Optional third-party tooling: report what is available, since features silently degrade
// without it
await WIKI.models.extensions.refreshFromDisk()
await WIKI.models.extensions.logState()
await WIKI.dbManager.subscribeToNotifications() await WIKI.dbManager.subscribeToNotifications()
await WIKI.scheduler.start() await WIKI.scheduler.start()
} }
@ -192,7 +198,9 @@ async function initHTTPServer() {
logger: { logger: {
level: 'error' level: 'error'
}, },
trustProxy: WIKI.config.security.securityTrustProxy ?? false, // -> `securityTrustProxy` was the 2.x name: the setting is `trustProxy`, so this read never
// matched and the option was permanently off no matter what the admin area showed
trustProxy: WIKI.config.security.trustProxy ?? false,
routerOptions: { routerOptions: {
ignoreTrailingSlash: true ignoreTrailingSlash: true
} }
@ -227,21 +235,43 @@ async function initHTTPServer() {
// Security // Security
// ---------------------------------------- // ----------------------------------------
// -> Every setting below comes from the admin area's security view. They are read once, here, so a
// change takes effect on the next restart — the view says as much.
const security = WIKI.config.security
app.register(fastifyHelmet, { app.register(fastifyHelmet, {
contentSecurityPolicy: false, // TODO: Make it configurable contentSecurityPolicy:
strictTransportSecurity: WIKI.config.security.securityHSTS security.enforceCsp && security.cspDirectives
? { directives: parseCspDirectives(security.cspDirectives), useDefaults: false }
: false,
strictTransportSecurity:
security.enforceHsts && security.hstsDuration > 0
? { ? {
maxAge: WIKI.config.security.securityHSTSDuration, maxAge: security.hstsDuration,
includeSubDomains: true includeSubDomains: true
} }
: false : false,
// -> Helmet's own default is `sameorigin`, which is also what this setting turned off means
xFrameOptions: { action: security.disallowIframe ? 'deny' : 'sameorigin' },
referrerPolicy: security.enforceSameOriginReferrerPolicy
? { policy: 'same-origin' }
: { policy: 'no-referrer' }
}) })
app.register(fastifyCors, { app.register(fastifyCors, {
origin: '*', // TODO: Make it configurable origin: corsOrigin(security),
methods: ['GET', 'HEAD', 'POST', 'OPTIONS'] methods: ['GET', 'HEAD', 'POST', 'OPTIONS']
}) })
if (security.disallowFloc) {
// -> Helmet dropped its FLoC helper once the proposal was withdrawn, but opting out still costs
// one header and the setting exists
app.addHook('onSend', (req, reply, payload, done) => {
reply.header('Permissions-Policy', 'interest-cohort=()')
done(null, payload)
})
}
// ---------------------------------------- // ----------------------------------------
// Public Assets // Public Assets
// ---------------------------------------- // ----------------------------------------
@ -377,6 +407,36 @@ async function initHTTPServer() {
logo: {} as any logo: {} as any
}) })
// ----------------------------------------
// API Key Authentication
// ----------------------------------------
app.decorateRequest('apiKey', null)
app.addHook('onRequest', async (req, reply) => {
// -> Bearer tokens authenticate API calls only; everything else is cookie-authenticated. Note
// that the session is deliberately left untouched: writing to it would have @fastify/session
// persist a session row for every scraped request.
if (!req.url.startsWith('/_api/')) {
return
}
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return
}
const token = header.slice('Bearer '.length).trim()
if (!token) {
return
}
try {
req.apiKey = await WIKI.models.apiKeys.verify(token)
} catch (err: any) {
// -> Say why: the caller holds the credential and can act on "revoked" or "expired"
WIKI.logger.debug(`Rejected an API key: ${err.message}`)
return reply.unauthorized(err.message)
}
})
// ---------------------------------------- // ----------------------------------------
// Permissions // Permissions
// ---------------------------------------- // ----------------------------------------
@ -384,19 +444,26 @@ async function initHTTPServer() {
app.addHook('preHandler', (req, reply, done) => { app.addHook('preHandler', (req, reply, done) => {
const routePermissions = req.routeOptions.config?.permissions const routePermissions = req.routeOptions.config?.permissions
if (routePermissions && routePermissions.length > 0) { if (routePermissions && routePermissions.length > 0) {
// -> A verified API key stands in for a session, carrying the permissions of the groups it was
// issued for
const permissions = req.apiKey
? req.apiKey.permissions
: req.session?.authenticated
? req.session.permissions
: null
// Unauthenticated / No Permissions // Unauthenticated / No Permissions
if (!req.session?.authenticated || !(req.session?.permissions?.length ?? 0)) { if (!permissions || permissions.length < 1) {
return reply.unauthorized() return reply.unauthorized()
} }
// Is Root Admin? // Is Root Admin?
if (!req.session.permissions!.includes('manage:system')) { if (!permissions.includes('manage:system')) {
// Check for at least 1 permission // Check for at least 1 permission
const isAllowed = routePermissions.some((perms) => { const isAllowed = routePermissions.some((perms) => {
// Check for all permissions // Check for all permissions
if (Array.isArray(perms)) { if (Array.isArray(perms)) {
return perms.every((perm) => req.session.permissions?.some((p) => p === perm)) return perms.every((perm) => permissions.some((p) => p === perm))
} else { } else {
return req.session.permissions?.some((p) => p === perms) return permissions.some((p) => p === perms)
} }
}) })
// Forbidden // Forbidden

@ -7,9 +7,12 @@
"admin.analytics.saveSuccess": "Analytics configuration saved successfully", "admin.analytics.saveSuccess": "Analytics configuration saved successfully",
"admin.analytics.subtitle": "Add analytics and tracking tools to your wiki", "admin.analytics.subtitle": "Add analytics and tracking tools to your wiki",
"admin.analytics.title": "Analytics", "admin.analytics.title": "Analytics",
"admin.api.copyFailed": "Could not copy the key to the clipboard.",
"admin.api.copyKeyTitle": "Copy API Key", "admin.api.copyKeyTitle": "Copy API Key",
"admin.api.copySuccess": "API key copied to the clipboard.",
"admin.api.createInvalidData": "Some fields are missing or have invalid data.", "admin.api.createInvalidData": "Some fields are missing or have invalid data.",
"admin.api.createSuccess": "API Key created successfully.", "admin.api.createSuccess": "API Key created successfully.",
"admin.api.createdOn": "Created on {date}",
"admin.api.disableButton": "Disable API", "admin.api.disableButton": "Disable API",
"admin.api.disabled": "API Disabled", "admin.api.disabled": "API Disabled",
"admin.api.enableButton": "Enable API", "admin.api.enableButton": "Enable API",
@ -19,6 +22,9 @@
"admin.api.expiration30d": "30 days", "admin.api.expiration30d": "30 days",
"admin.api.expiration3y": "3 years", "admin.api.expiration3y": "3 years",
"admin.api.expiration90d": "90 days", "admin.api.expiration90d": "90 days",
"admin.api.expired": "Expired",
"admin.api.expiredHint": "This key is past its expiration date and can no longer be used.",
"admin.api.expiresOn": "Expires on {date}",
"admin.api.groupSelected": "Use {group} group permissions", "admin.api.groupSelected": "Use {group} group permissions",
"admin.api.groupsMissing": "You must select at least 1 group for this key.", "admin.api.groupsMissing": "You must select at least 1 group for this key.",
"admin.api.groupsSelected": "Use permissions from {count} groups", "admin.api.groupsSelected": "Use permissions from {count} groups",
@ -29,6 +35,8 @@
"admin.api.headerName": "Name", "admin.api.headerName": "Name",
"admin.api.headerRevoke": "Revoke", "admin.api.headerRevoke": "Revoke",
"admin.api.key": "API Key", "admin.api.key": "API Key",
"admin.api.keyEndingIn": "Ending in {suffix}",
"admin.api.loadFailed": "Failed to load API keys.",
"admin.api.nameInvalidChars": "Key name has invalid characters.", "admin.api.nameInvalidChars": "Key name has invalid characters.",
"admin.api.nameMissing": "Key name is missing.", "admin.api.nameMissing": "Key name is missing.",
"admin.api.newKeyButton": "New API Key", "admin.api.newKeyButton": "New API Key",
@ -51,6 +59,7 @@
"admin.api.noKeyInfo": "No API keys have been generated yet.", "admin.api.noKeyInfo": "No API keys have been generated yet.",
"admin.api.none": "There are no API keys yet.", "admin.api.none": "There are no API keys yet.",
"admin.api.permissionGroups": "Group Permissions", "admin.api.permissionGroups": "Group Permissions",
"admin.api.permissionsFrom": "Permissions from {groups}",
"admin.api.refreshSuccess": "List of API keys has been refreshed.", "admin.api.refreshSuccess": "List of API keys has been refreshed.",
"admin.api.revoke": "Revoke", "admin.api.revoke": "Revoke",
"admin.api.revokeConfirm": "Revoke API Key?", "admin.api.revokeConfirm": "Revoke API Key?",
@ -62,10 +71,13 @@
"admin.api.title": "API Access", "admin.api.title": "API Access",
"admin.api.toggleStateDisabledSuccess": "API has been disabled successfully.", "admin.api.toggleStateDisabledSuccess": "API has been disabled successfully.",
"admin.api.toggleStateEnabledSuccess": "API has been enabled successfully.", "admin.api.toggleStateEnabledSuccess": "API has been enabled successfully.",
"admin.api.toggleStateFailed": "Failed to switch the API state.",
"admin.approval.title": "Approvals", "admin.approval.title": "Approvals",
"admin.audit.title": "Audit Log", "admin.audit.title": "Audit Log",
"admin.auth.activeStrategies": "Active Strategies", "admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addFailed": "Failed to add the strategy.",
"admin.auth.addStrategy": "Add Strategy", "admin.auth.addStrategy": "Add Strategy",
"admin.auth.addSuccess": "{strategy} has been added.",
"admin.auth.allowedEmailRegex": "Allowed Email Address Regex", "admin.auth.allowedEmailRegex": "Allowed Email Address Regex",
"admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.", "admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.",
"admin.auth.allowedWebOrigins": "Allowed Web Origins", "admin.auth.allowedWebOrigins": "Allowed Web Origins",
@ -74,6 +86,11 @@
"admin.auth.callbackUrl": "Callback URL / Redirect URI", "admin.auth.callbackUrl": "Callback URL / Redirect URI",
"admin.auth.configReference": "Configuration Reference", "admin.auth.configReference": "Configuration Reference",
"admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.", "admin.auth.configReferenceSubtitle": "Some strategies may require some configuration values to be set on your provider. These are provided for reference only and may not be needed by the current strategy.",
"admin.auth.deleteConfirm": "Are you sure you want to delete the {strategy} strategy? Users who can only sign in through it will lose access.",
"admin.auth.deleteFailed": "Failed to delete the strategy.",
"admin.auth.deleteLocalForbidden": "Every account is registered against the local strategy, so it cannot be deleted.",
"admin.auth.deleteStrategy": "Delete Strategy",
"admin.auth.deleteSuccess": "{strategy} has been deleted.",
"admin.auth.displayName": "Display Name", "admin.auth.displayName": "Display Name",
"admin.auth.displayNameHint": "The title shown to the end user for this authentication strategy.", "admin.auth.displayNameHint": "The title shown to the end user for this authentication strategy.",
"admin.auth.emailValidation": "Email Validation", "admin.auth.emailValidation": "Email Validation",
@ -87,13 +104,17 @@
"admin.auth.info": "Info", "admin.auth.info": "Info",
"admin.auth.infoName": "Name", "admin.auth.infoName": "Name",
"admin.auth.infoNameHint": "Display name for this strategy.", "admin.auth.infoNameHint": "Display name for this strategy.",
"admin.auth.loadFailed": "Failed to load the authentication configuration.",
"admin.auth.loginUrl": "Login URL", "admin.auth.loginUrl": "Login URL",
"admin.auth.logoutUrl": "Logout URL", "admin.auth.logoutUrl": "Logout URL",
"admin.auth.noConfigOption": "This strategy has no configuration options you can modify.", "admin.auth.noConfigOption": "This strategy has no configuration options you can modify.",
"admin.auth.noModulesToAdd": "No other authentication module is installed on this server.",
"admin.auth.refreshSuccess": "List of strategies has been refreshed.", "admin.auth.refreshSuccess": "List of strategies has been refreshed.",
"admin.auth.registration": "Registration", "admin.auth.registration": "Registration",
"admin.auth.registrationHint": "Allow any user successfully authorized by the strategy to access the wiki.", "admin.auth.registrationHint": "Allow any user successfully authorized by the strategy to access the wiki.",
"admin.auth.registrationLocalHint": "Whether to allow guests to register new accounts.", "admin.auth.registrationLocalHint": "Whether to allow guests to register new accounts.",
"admin.auth.registrationNotEnforced": "Saved but not enforced yet: self-registration is not implemented.",
"admin.auth.saveFailed": "Failed to save {strategy}.",
"admin.auth.saveSuccess": "Authentication configuration saved successfully.", "admin.auth.saveSuccess": "Authentication configuration saved successfully.",
"admin.auth.security": "Security", "admin.auth.security": "Security",
"admin.auth.siteUrlNotSetup": "You must set a valid {siteUrl} first! Click on {general} in the left sidebar.", "admin.auth.siteUrlNotSetup": "You must set a valid {siteUrl} first! Click on {general} in the left sidebar.",
@ -203,19 +224,22 @@
"admin.extensions.installingHint": "This may take a while depending on your server.", "admin.extensions.installingHint": "This may take a while depending on your server.",
"admin.extensions.instructions": "Instructions", "admin.extensions.instructions": "Instructions",
"admin.extensions.instructionsHint": "Must be installed manually", "admin.extensions.instructionsHint": "Must be installed manually",
"admin.extensions.loadFailed": "Failed to load extensions.",
"admin.extensions.reinstall": "Reinstall", "admin.extensions.reinstall": "Reinstall",
"admin.extensions.requiresSharp": "Requires Sharp extension", "admin.extensions.requiresSharp": "Requires Sharp extension",
"admin.extensions.subtitle": "Install extensions for extra functionality", "admin.extensions.subtitle": "Install extensions for extra functionality",
"admin.extensions.title": "Extensions", "admin.extensions.title": "Extensions",
"admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.", "admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.",
"admin.flags.advanced.label": "Custom Configuration", "admin.flags.advanced.label": "Custom Configuration",
"admin.flags.advanced.notImplemented": "The editor for custom flags is not available yet, and nothing reads custom keys so far.",
"admin.flags.authDebug.hint": "Log detailed debug info of all login / registration attempts.", "admin.flags.authDebug.hint": "Log detailed debug info of all login / registration attempts.",
"admin.flags.authDebug.label": "Auth Debug", "admin.flags.authDebug.label": "Auth Debug",
"admin.flags.experimental.hint": "Enable unstable / unfinished features. DO NOT enable in a production environment!", "admin.flags.experimental.hint": "Enable unstable / unfinished features. DO NOT enable in a production environment!",
"admin.flags.experimental.label": "Experimental Features", "admin.flags.experimental.label": "Experimental Features",
"admin.flags.getTokenHint": "Copy your current authentication token for use in GraphQL API testing.", "admin.flags.loadFailed": "Failed to fetch system flags.",
"admin.flags.getTokenLabel": "Get Current Token", "admin.flags.saveFailed": "Failed to save system flags.",
"admin.flags.saveSuccess": "Flags have been updated successfully.", "admin.flags.saveSuccess": "Flags have been updated successfully.",
"admin.flags.serverLogNotice": "Auth Debug and SQL Query Logging take effect immediately and write to the server log.",
"admin.flags.sqlLog.hint": "Log all queries made to the database to console.", "admin.flags.sqlLog.hint": "Log all queries made to the database to console.",
"admin.flags.sqlLog.label": "SQL Query Logging", "admin.flags.sqlLog.label": "SQL Query Logging",
"admin.flags.subtitle": "Low-level system flags for debugging or experimental purposes", "admin.flags.subtitle": "Low-level system flags for debugging or experimental purposes",
@ -591,14 +615,20 @@
"admin.search.configSaveSuccess": "Search engine configuration saved successfully.", "admin.search.configSaveSuccess": "Search engine configuration saved successfully.",
"admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides", "admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides",
"admin.search.dictOverridesHint": "JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. {0}", "admin.search.dictOverridesHint": "JSON object of 2 letters locale codes and their PostgreSQL dictionary association. e.g. {0}",
"admin.search.dictOverridesInvalidJSON": "The dictionary mapping is not valid JSON. {reason}",
"admin.search.dictOverridesNotAnObject": "The dictionary mapping must be a JSON object, e.g. { \"en\": \"english\" }.",
"admin.search.dictOverridesUnknown": "{dictionary} is not a PostgreSQL dictionary available in this database (mapped to {locale}).",
"admin.search.engineConfig": "Engine Configuration", "admin.search.engineConfig": "Engine Configuration",
"admin.search.engineNoConfig": "This engine has no configuration options you can modify.", "admin.search.engineNoConfig": "This engine has no configuration options you can modify.",
"admin.search.highlighting": "Enable Term Highlighting", "admin.search.highlighting": "Enable Term Highlighting",
"admin.search.highlightingHint": "Whether to show the highlighted terms in search results. There is a slight performance impact when enabled.", "admin.search.highlightingHint": "Whether to show the highlighted terms in search results. There is a slight performance impact when enabled.",
"admin.search.indexRebuildSuccess": "Index rebuilt successfully.", "admin.search.indexRebuildSuccess": "Index rebuilt successfully.",
"admin.search.listRefreshSuccess": "List of search engines has been refreshed.", "admin.search.listRefreshSuccess": "List of search engines has been refreshed.",
"admin.search.loadFailed": "Failed to load the search configuration.",
"admin.search.rebuildFailed": "Failed to queue a search index rebuild.",
"admin.search.rebuildIndex": "Rebuild Index", "admin.search.rebuildIndex": "Rebuild Index",
"admin.search.rebuildInitSuccess": "A search index rebuild has been initiated and will start shortly.", "admin.search.rebuildInitSuccess": "A search index rebuild has been initiated and will start shortly.",
"admin.search.saveFailed": "Failed to save the search configuration.",
"admin.search.saveSuccess": "Search engine configuration saved successfully", "admin.search.saveSuccess": "Search engine configuration saved successfully",
"admin.search.searchEngine": "Search Engine", "admin.search.searchEngine": "Search Engine",
"admin.search.subtitle": "Configure the search capabilities of your wiki", "admin.search.subtitle": "Configure the search capabilities of your wiki",
@ -631,13 +661,17 @@
"admin.security.jwt": "JWT Configuration", "admin.security.jwt": "JWT Configuration",
"admin.security.jwtAudience": "JWT Audience", "admin.security.jwtAudience": "JWT Audience",
"admin.security.jwtAudienceHint": "Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)", "admin.security.jwtAudienceHint": "Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)",
"admin.security.loadFailed": "Failed to load the security configuration.",
"admin.security.loginScreen": "Login Screen", "admin.security.loginScreen": "Login Screen",
"admin.security.maxUploadBatch": "Max Files per Upload", "admin.security.maxUploadBatch": "Max Files per Upload",
"admin.security.maxUploadBatchHint": "How many files can be uploaded in a single batch?", "admin.security.maxUploadBatchHint": "How many files can be uploaded in a single batch?",
"admin.security.maxUploadBatchSuffix": "files", "admin.security.maxUploadBatchSuffix": "files",
"admin.security.maxUploadSize": "Max Upload Size", "admin.security.maxUploadSize": "Max Upload Size",
"admin.security.maxUploadSizeHint": "The maximum size for a single file. Final value in base 2.", "admin.security.maxUploadSizeHint": "The maximum size for a single file. Final value in base 2.",
"admin.security.maxUploadSizeInvalid": "The maximum upload size must be a positive value, e.g. 10 MB.",
"admin.security.maxUploadSizeSuffix": "bytes", "admin.security.maxUploadSizeSuffix": "bytes",
"admin.security.restartRequired": "Header, CORS and proxy settings are applied when the server starts, so they take effect after a restart.",
"admin.security.saveFailed": "Failed to save the security configuration.",
"admin.security.saveSuccess": "Security configuration updated successfully.", "admin.security.saveSuccess": "Security configuration updated successfully.",
"admin.security.scanSVG": "Scan and Sanitize SVG Uploads", "admin.security.scanSVG": "Scan and Sanitize SVG Uploads",
"admin.security.scanSVGHint": "Should SVG uploads be scanned for vulnerabilities and stripped of any potentially unsafe content.", "admin.security.scanSVGHint": "Should SVG uploads be scanned for vulnerabilities and stripped of any potentially unsafe content.",
@ -652,6 +686,7 @@
"admin.security.trustProxyHint": "Should be enabled when using a reverse-proxy like nginx, apache, CloudFlare, etc in front of Wiki.js. Turn off otherwise.", "admin.security.trustProxyHint": "Should be enabled when using a reverse-proxy like nginx, apache, CloudFlare, etc in front of Wiki.js. Turn off otherwise.",
"admin.security.uploads": "Uploads", "admin.security.uploads": "Uploads",
"admin.security.uploadsInfo": "These settings only affect Wiki.js. If you're using a reverse-proxy (e.g. nginx, Apache, Cloudflare), you must also change its settings to match.", "admin.security.uploadsInfo": "These settings only affect Wiki.js. If you're using a reverse-proxy (e.g. nginx, Apache, Cloudflare), you must also change its settings to match.",
"admin.security.uploadsNotEnforced": "These limits are saved but not enforced yet: uploading is not implemented.",
"admin.security.warn": "Make sure to understand the implications before turning on / off a security feature.", "admin.security.warn": "Make sure to understand the implications before turning on / off a security feature.",
"admin.sites.activate": "Activate Site", "admin.sites.activate": "Activate Site",
"admin.sites.activateConfirm": "Are you sure you want activate site {siteTitle}? The site will become accessible to users with read access.", "admin.sites.activateConfirm": "Are you sure you want activate site {siteTitle}? The site will become accessible to users with read access.",
@ -1144,6 +1179,7 @@
"admin.webhooks.eventEditComment": "Edit an existing comment", "admin.webhooks.eventEditComment": "Edit an existing comment",
"admin.webhooks.eventEditPage": "Update an existing page", "admin.webhooks.eventEditPage": "Update an existing page",
"admin.webhooks.eventNewComment": "Post a new comment", "admin.webhooks.eventNewComment": "Post a new comment",
"admin.webhooks.eventNotEmitted": "Not emitted yet — this part of the wiki is not implemented.",
"admin.webhooks.eventRenameAsset": "Rename / move an asset", "admin.webhooks.eventRenameAsset": "Rename / move an asset",
"admin.webhooks.eventRenamePage": "Rename / move a page", "admin.webhooks.eventRenamePage": "Rename / move a page",
"admin.webhooks.eventUploadAsset": "Upload a new asset", "admin.webhooks.eventUploadAsset": "Upload a new asset",
@ -1157,6 +1193,7 @@
"admin.webhooks.includeContentHint": "Should the payload include content (e.g. the full page body). Make sure that your remote endpoint can accept large payloads!", "admin.webhooks.includeContentHint": "Should the payload include content (e.g. the full page body). Make sure that your remote endpoint can accept large payloads!",
"admin.webhooks.includeMetadata": "Include Metadata", "admin.webhooks.includeMetadata": "Include Metadata",
"admin.webhooks.includeMetadataHint": "Should the payload include metadata such as title, description, author, etc.", "admin.webhooks.includeMetadataHint": "Should the payload include metadata such as title, description, author, etc.",
"admin.webhooks.loadFailed": "Failed to load webhooks.",
"admin.webhooks.nameInvalidChars": "The name contains invalid characters.", "admin.webhooks.nameInvalidChars": "The name contains invalid characters.",
"admin.webhooks.nameMissing": "A name for this webhook is required.", "admin.webhooks.nameMissing": "A name for this webhook is required.",
"admin.webhooks.new": "New Webhook", "admin.webhooks.new": "New Webhook",

@ -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()

@ -1,11 +1,55 @@
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { eq } from 'drizzle-orm' import { asc, eq } from 'drizzle-orm'
import { parseModuleProps } from '../helpers/common.ts' import { parseModuleProps } from '../helpers/common.ts'
import { authentication as authenticationTable } from '../db/schema.ts' import { authentication as authenticationTable, groups as groupsTable } from '../db/schema.ts'
import type { ModuleProp } from '../helpers/common.ts'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
/** An authentication module, as declared by its `definition.yml`. */
export interface AuthModule {
key: string
title: string
description: string
logo?: string
icon?: string
color?: string
vendor?: string
website?: string
isAvailable: boolean
useForm: boolean
usernameType: string
props: Record<string, ModuleProp>
refs?: Record<string, { title?: string; hint?: string; icon?: string; value: string }>
}
/** A configured instance of an authentication module. */
export interface AuthStrategy {
id: string
module: string
displayName: string
isEnabled: boolean
registration: boolean
allowedEmailRegex: string
autoEnrollGroups: string[]
config: Record<string, any>
}
/** The module every wiki ships with. */
const LOCAL_MODULE = 'local'
/**
* Whether this is the strategy the instance was seeded with.
*
* Not merely "a local strategy": every account's password is stored under this exact strategy ID
* (see `models/users.ts`), so it is the one that cannot be disabled or deleted. A second instance of
* the local module holds no credentials and is as disposable as any other strategy.
*/
function isBuiltInLocal(id: string): boolean {
return id === WIKI.data.systemIds.localAuthId
}
/** /**
* Authentication model * Authentication model
*/ */
@ -21,6 +65,277 @@ class Authentication {
.where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined) .where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined)
} }
/**
* The authentication modules found on disk, in the order the admin area lists them
*/
getModules(): AuthModule[] {
return [...((WIKI.data.authentication ?? []) as AuthModule[])].sort((a, b) =>
a.key === LOCAL_MODULE ? -1 : b.key === LOCAL_MODULE ? 1 : a.title.localeCompare(b.title)
)
}
/**
* A single module definition, or null when nothing on disk declares that key
*/
getModule(key: string): AuthModule | null {
return this.getModules().find((m) => m.key === key) ?? null
}
/**
* Every configured strategy, the built-in local one first, then alphabetically by display name.
*
* Config values are completed from the module's declared defaults, so a prop added to a module
* after a strategy was configured is returned with its default rather than as a missing key.
*/
async getActiveStrategies(): Promise<AuthStrategy[]> {
const strategies = await WIKI.db
.select()
.from(authenticationTable)
.orderBy(asc(authenticationTable.displayName))
return strategies
.map((stg) => ({
...stg,
autoEnrollGroups: stg.autoEnrollGroups ?? [],
config: this.buildConfig(stg.module, {}, stg.config as Record<string, any>)
}))
.sort((a, b) => (isBuiltInLocal(a.id) ? -1 : isBuiltInLocal(b.id) ? 1 : 0))
}
/**
* A single configured strategy, or null if there is no such strategy
*/
async getStrategyById(id: string): Promise<AuthStrategy | null> {
return (await this.getActiveStrategies()).find((stg) => stg.id === id) ?? null
}
/**
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
*
* Read-only props are never taken from the client: they are declarations of something the server
* does not support changing, so the stored value (or the module default) always wins.
*/
buildConfig(
moduleKey: string,
incoming: Record<string, any> = {},
existing: Record<string, any> = {}
): Record<string, any> {
const props = this.getModule(moduleKey)?.props ?? {}
const config: Record<string, any> = {}
for (const [key, prop] of Object.entries(props)) {
const current = existing[key] !== undefined ? existing[key] : prop.default
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
}
return config
}
/**
* Check incoming config values against what the module declares.
*
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them —
* without this, a boolean prop would happily store the string `"maybe"`.
*
* @returns The reason it is invalid, or null when it is fine
*/
validateConfig(moduleKey: string, incoming: Record<string, any> = {}): string | null {
const props = this.getModule(moduleKey)?.props ?? {}
for (const [key, value] of Object.entries(incoming)) {
const prop = props[key]
// -> Unknown keys are dropped by buildConfig rather than refused: a module losing a prop must
// not make the admin area unable to save
if (!prop || prop.readOnly || value === undefined) {
continue
}
if (prop.enum) {
// -> Enum entries are declared as `value` or `value|label`
const allowed = prop.enum.map((entry) => entry.split('|')[0])
if (!allowed.includes(`${value}`)) {
return `"${value}" is not a valid value for ${prop.title}.`
}
continue
}
switch (prop.type) {
case 'boolean':
if (typeof value !== 'boolean') {
return `${prop.title} must be true or false.`
}
break
case 'number':
if (typeof value !== 'number' || !Number.isFinite(value)) {
return `${prop.title} must be a number.`
}
break
default:
if (typeof value !== 'string') {
return `${prop.title} must be a string.`
}
}
}
return null
}
/**
* Check the fields shared by every strategy, whichever module it uses.
*
* @param strategy The values as they will end up stored, i.e. already merged with the current ones
* @returns The reason it is invalid, or null when it is fine
*/
async validateStrategy(strategy: {
/** Omitted when the strategy does not exist yet, i.e. on create. */
id?: string
module: string
displayName?: string
isEnabled?: boolean
allowedEmailRegex?: string
autoEnrollGroups?: string[]
}): Promise<string | null> {
if (strategy.displayName !== undefined && strategy.displayName.trim().length < 1) {
return 'The display name cannot be empty.'
}
if (strategy.id && isBuiltInLocal(strategy.id) && strategy.isEnabled === false) {
return 'The built-in local strategy cannot be disabled, as it would leave no way to log in.'
}
if (strategy.allowedEmailRegex) {
try {
new RegExp(strategy.allowedEmailRegex)
} catch (err: any) {
return `The allowed email pattern is not a valid regular expression: ${err.message}`
}
}
if (strategy.autoEnrollGroups && strategy.autoEnrollGroups.length > 0) {
if (strategy.autoEnrollGroups.includes(WIKI.data.systemIds.guestsGroupId)) {
return 'The guests group cannot be used for auto-enrollment.'
}
const existing = await WIKI.db.select({ id: groupsTable.id }).from(groupsTable)
const existingIds = existing.map((g) => g.id)
const unknown = strategy.autoEnrollGroups.find((id) => !existingIds.includes(id))
if (unknown) {
return `Group ${unknown} does not exist.`
}
}
return null
}
/**
* Configure a new instance of a module
*
* @returns The new strategy's ID
*/
async createStrategy(values: {
module: string
displayName?: string
isEnabled?: boolean
registration?: boolean
allowedEmailRegex?: string
autoEnrollGroups?: string[]
config?: Record<string, any>
}): Promise<string> {
const mod = this.getModule(values.module)!
const result = await WIKI.db
.insert(authenticationTable)
.values({
module: values.module,
displayName: values.displayName?.trim() || mod.title,
isEnabled: values.isEnabled ?? true,
registration: values.registration ?? false,
allowedEmailRegex: values.allowedEmailRegex ?? '',
autoEnrollGroups: values.autoEnrollGroups ?? [],
config: this.buildConfig(values.module, values.config)
})
.returning({ id: authenticationTable.id })
await this.activateStrategies()
return result[0].id
}
/**
* Update a configured strategy.
*
* The strategies are reloaded afterwards, so a config change takes effect on the next login rather
* than on the next restart.
*
* @returns Whether a strategy was updated
*/
async updateStrategy(
id: string,
patch: {
displayName?: string
isEnabled?: boolean
registration?: boolean
allowedEmailRegex?: string
autoEnrollGroups?: string[]
config?: Record<string, any>
}
): Promise<boolean> {
const current = await this.getStrategyById(id)
if (!current) {
return false
}
const values: Partial<typeof authenticationTable.$inferInsert> = {}
if (patch.displayName !== undefined) {
values.displayName = patch.displayName.trim()
}
if (patch.isEnabled !== undefined) {
values.isEnabled = patch.isEnabled
}
if (patch.registration !== undefined) {
values.registration = patch.registration
}
if (patch.allowedEmailRegex !== undefined) {
values.allowedEmailRegex = patch.allowedEmailRegex
}
if (patch.autoEnrollGroups !== undefined) {
values.autoEnrollGroups = patch.autoEnrollGroups
}
if (patch.config !== undefined) {
values.config = this.buildConfig(current.module, patch.config, current.config)
}
if (Object.keys(values).length < 1) {
return false
}
const result = await WIKI.db
.update(authenticationTable)
.set(values)
.where(eq(authenticationTable.id, id))
if ((result.rowCount ?? 0) < 1) {
return false
}
await this.activateStrategies()
return true
}
/**
* Delete a configured strategy, and stop every site from offering it.
*
* Users whose only credentials belong to this strategy lose their way in, which is why the built-in
* local strategy — the one every account is seeded against — cannot be deleted.
*
* @returns Whether a strategy was deleted
*/
async deleteStrategy(id: string): Promise<boolean> {
const result = await WIKI.db.delete(authenticationTable).where(eq(authenticationTable.id, id))
if ((result.rowCount ?? 0) < 1) {
return false
}
// -> Sites keep their own ordered list of strategy IDs, which would otherwise keep a dangling one
for (const site of await WIKI.models.sites.getAllSites()) {
const configured = ((site.config as Record<string, any>)?.authStrategies ?? []) as Array<{
id: string
}>
if (configured.some((s) => s.id === id)) {
await WIKI.models.sites.updateSite(site.id, {
config: { authStrategies: configured.filter((s) => s.id !== id) }
})
}
}
await this.activateStrategies()
return true
}
async refreshStrategiesFromDisk(): Promise<void> { async refreshStrategiesFromDisk(): Promise<void> {
try { try {
// -> Fetch definitions from disk // -> Fetch definitions from disk

@ -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()

@ -1,19 +1,31 @@
import { apiKeys } from './apiKeys.ts'
import { authentication } from './authentication.ts' import { authentication } from './authentication.ts'
import { blocks } from './blocks.ts' import { blocks } from './blocks.ts'
import { extensions } from './extensions.ts'
import { flags } from './flags.ts'
import { groups } from './groups.ts' import { groups } from './groups.ts'
import { hooks } from './hooks.ts'
import { jobs } from './jobs.ts' import { jobs } from './jobs.ts'
import { locales } from './locales.ts' import { locales } from './locales.ts'
import { search } from './search.ts'
import { security } from './security.ts'
import { sessions } from './sessions.ts' import { sessions } from './sessions.ts'
import { settings } from './settings.ts' import { settings } from './settings.ts'
import { sites } from './sites.ts' import { sites } from './sites.ts'
import { users } from './users.ts' import { users } from './users.ts'
export default { export default {
apiKeys,
authentication, authentication,
blocks, blocks,
extensions,
flags,
groups, groups,
hooks,
jobs, jobs,
locales, locales,
search,
security,
sessions, sessions,
settings, settings,
sites, sites,

@ -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()

@ -152,9 +152,10 @@ class Settings {
forceAssetDownload: true, forceAssetDownload: true,
hstsDuration: 0, hstsDuration: 0,
trustProxy: false, trustProxy: false,
authJwtAudience: 'urn:wiki.js', // NOTE: the JWT audience, expiration and renewal period are deliberately absent here.
authJwtExpiration: '30m', // They used to be duplicated under 2.x names (`authJwt*`) that nothing read, so the
authJwtRenewablePeriod: '14d', // admin area edited values with no effect. They live in the `auth` settings above,
// which is what the server uses; the security view maps onto those.
uploadMaxFileSize: 10485760, uploadMaxFileSize: 10485760,
uploadMaxFiles: 20, uploadMaxFiles: 20,
uploadScanSVG: true uploadScanSVG: true

@ -289,6 +289,19 @@ class Users {
if (groups.length > 0) { if (groups.length > 0) {
await this.setUserGroups(userId, groups) await this.setUserGroups(userId, groups)
} }
WIKI.models.flags.authDebug(
`Created user ${userId} <${email.toLowerCase()}> in ${groups.length} group(s), mustChangePwd: ${mustChangePassword}, verified: ${isVerified}`
)
await WIKI.models.hooks.emit('user:join', {
userId,
metadata: {
name,
email: email.toLowerCase()
}
})
return userId return userId
} }
@ -509,8 +522,21 @@ class Users {
}) })
} }
// -> Never the password, flag or no flag
WIKI.models.flags.authDebug(
`Login attempt on site ${siteId} using ${str.module} strategy ${strategyId}${username ? ` as "${username}"` : ''} from ${ip}`
)
// Authenticate // Authenticate
const user = await str.authenticate(context) let user
try {
user = await str.authenticate(context)
} catch (err: any) {
WIKI.models.flags.authDebug(
`Strategy ${str.module} rejected the attempt${username ? ` for "${username}"` : ''}: ${err.message}`
)
throw err
}
// Perform post-login checks // Perform post-login checks
return this.afterLoginChecks( return this.afterLoginChecks(
@ -524,6 +550,7 @@ class Users {
req req
) )
} else { } else {
WIKI.models.flags.authDebug(`Login attempt using unknown strategy ${strategyId} from ${ip}`)
throw new Error('Invalid Strategy ID') throw new Error('Invalid Strategy ID')
} }
} }
@ -590,6 +617,9 @@ class Users {
strategyId strategyId
} }
}) })
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but a 2FA code is required first`
)
return { return {
nextAction: 'provideTfa', nextAction: 'provideTfa',
continuationToken: tfaToken, continuationToken: tfaToken,
@ -612,6 +642,9 @@ class Users {
strategyId strategyId
} }
}) })
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but must set up 2FA first`
)
return { return {
nextAction: 'setupTfa', nextAction: 'setupTfa',
continuationToken: tfaToken, continuationToken: tfaToken,
@ -636,6 +669,9 @@ class Users {
} }
}) })
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> authenticated, but must change their password first`
)
return { return {
nextAction: 'changePassword', nextAction: 'changePassword',
continuationToken: pwdChangeToken, continuationToken: pwdChangeToken,
@ -650,6 +686,22 @@ class Users {
// Set Session Data // Set Session Data
this.updateSession(user, req) this.updateSession(user, req)
WIKI.models.flags.authDebug(
`User ${user.id} <${user.email}> logged in with ${user.groups.length} group(s) and ${req?.session?.permissions?.length ?? 0} permission(s), redirecting to ${redirect}`
)
// -> Only once the login has actually succeeded: an attempt stopped by 2FA or a forced password
// change is not a login yet
await WIKI.models.hooks.emit('user:login', {
userId: user.id,
strategyId,
ip: context.ip,
metadata: {
name: user.name,
email: user.email
}
})
return { return {
authenticated: true, authenticated: true,
nextAction: 'redirect', nextAction: 'redirect',

@ -14,9 +14,13 @@ props:
enforceTfa: enforceTfa:
type: Boolean type: Boolean
title: Enforce Two-Factor Authentication title: Enforce Two-Factor Authentication
hint: Users will be required to setup 2FA the first time they login and cannot be disabled by the user. # Read-only until 2FA works end to end: `afterLoginChecks` reaches for a `generateTFA()` that does
# not exist, and there is no route to submit a code, so a login that needs 2FA can only fail.
# See the FIXME comments in models/users.ts.
hint: Not available yet — two-factor authentication is not implemented in this version.
icon: pin-pad icon: pin-pad
default: false default: false
readOnly: true
emailValidation: emailValidation:
type: Boolean type: Boolean
title: Email Validation title: Email Validation

@ -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()
}

@ -7,8 +7,17 @@
import 'fastify' import 'fastify'
import '@fastify/session' import '@fastify/session'
import type { ApiKeyIdentity } from '../models/apiKeys.ts'
declare module 'fastify' { declare module 'fastify' {
interface FastifyRequest {
/**
* Set by the API key hook in `index.ts` when a request carries a valid bearer key. Null for
* cookie-authenticated and anonymous requests.
*/
apiKey?: ApiKeyIdentity | null
}
interface Session { interface Session {
/** Set by `models/users.ts` → `updateSession()` once a login completes. */ /** Set by `models/users.ts` → `updateSession()` once a login completes. */
authenticated?: boolean authenticated?: boolean

@ -16,6 +16,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
type='textarea' type='textarea'
outlined outlined
:model-value='props.keyValue' :model-value='props.keyValue'
readonly
dense dense
hide-bottom-space hide-bottom-space
:label='t(`admin.api.key`)' :label='t(`admin.api.key`)'
@ -24,6 +25,16 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
) )
q-card-actions.card-actions q-card-actions.card-actions
q-space q-space
//- The dialog is the only place this token ever appears, so copying it must not depend on
//- selecting a wrapped 700-character string by hand
q-btn.acrylic-btn(
flat
icon='las la-copy'
:label='t(`common.actions.copy`)'
color='primary'
padding='xs md'
@click='copyKey'
)
q-btn( q-btn(
unelevated unelevated
:label='t(`common.actions.close`)' :label='t(`common.actions.close`)'
@ -35,7 +46,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar' import { copyToClipboard, useDialogPluginComponent, useQuasar } from 'quasar'
// PROPS // PROPS
@ -60,4 +71,22 @@ const $q = useQuasar()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()
// METHODS
async function copyKey () {
try {
await copyToClipboard(props.keyValue)
$q.notify({
type: 'positive',
message: t('admin.api.copySuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.api.copyFailed'),
caption: err.message
})
}
}
</script> </script>

@ -24,11 +24,12 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
q-item q-item
blueprint-icon.self-start(icon='schedule') blueprint-icon.self-start(icon='schedule')
q-item-section q-item-section
//- Single-select: a key has one lifetime. It was declared `multiple` against a string
//- model, which showed the default as a stray chip and let several be picked at once.
q-select( q-select(
outlined outlined
:options='expirations' :options='expirations'
v-model='state.keyExpiration' v-model='state.keyExpiration'
multiple
map-options map-options
option-value='value' option-value='value'
option-label='text' option-label='text'
@ -105,7 +106,6 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
<script setup> <script setup>
import { cloneDeep, sampleSize } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar' import { useDialogPluginComponent, useQuasar } from 'quasar'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
@ -135,9 +135,16 @@ const state = reactive({
keyGroups: [], keyGroups: [],
groups: [], groups: [],
loadingGroups: false, loadingGroups: false,
loading: false loading: 0
}) })
/**
* The guests group is anonymous access, so a key carrying its permissions would grant nothing a
* caller cannot already do. Its ID is fixed at install (`systemIds.guestsGroupId` in base.yml), and
* the API rejects it too.
*/
const GUESTS_GROUP_ID = '10000000-0000-4000-8000-000000000001'
const expirations = [ const expirations = [
{ value: '30d', text: t('admin.api.expiration30d') }, { value: '30d', text: t('admin.api.expiration30d') },
{ value: '90d', text: t('admin.api.expiration90d') }, { value: '90d', text: t('admin.api.expiration90d') },
@ -173,18 +180,16 @@ const keyGroupsValidation = [
async function loadGroups () { async function loadGroups () {
state.loading++ state.loading++
state.loadingGroups = true state.loadingGroups = true
const resp = await APOLLO_CLIENT.query({ try {
query: ` const resp = await API_CLIENT.get('groups').json()
query getGroupsForCreateApiKey { state.groups = (resp ?? []).filter(g => g.id !== GUESTS_GROUP_ID)
groups { } catch (err) {
id $q.notify({
name type: 'negative',
} message: t('admin.users.groupsLoadFailed'),
} caption: err.message
`,
fetchPolicy: 'network-only'
}) })
state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? []) }
state.loadingGroups = false state.loadingGroups = false
state.loading-- state.loading--
} }
@ -196,52 +201,34 @@ async function create () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('admin.api.createInvalidData')) throw new Error(t('admin.api.createInvalidData'))
} }
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.post('api-keys', {
mutation: ` json: {
mutation createApiKey (
$name: String!
$expiration: String!
$groups: [UUID]!
) {
createApiKey (
name: $name
expiration: $expiration
groups: $groups
) {
operation {
succeeded
message
}
key
}
}
`,
variables: {
name: state.keyName, name: state.keyName,
expiration: state.keyExpiration, expiration: state.keyExpiration,
groups: state.keyGroups groups: state.keyGroups
} }
}) }).json()
if (resp?.data?.createApiKey?.operation?.succeeded) { if (!resp?.ok || !resp?.key) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.api.createSuccess') message: t('admin.api.createSuccess')
}) })
// -> The token exists only in this response, so hand it straight to the copy dialog
$q.dialog({ $q.dialog({
component: ApiKeyCopyDialog, component: ApiKeyCopyDialog,
componentProps: { componentProps: {
keyValue: resp?.data?.createApiKey?.key || 'ERROR' keyValue: resp.key
} }
}).onDismiss(() => { }).onDismiss(() => {
onDialogOK() onDialogOK()
}) })
} else {
throw new Error(resp?.data?.createApiKey?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
} }
state.loading-- state.loading--

@ -69,34 +69,21 @@ const state = reactive({
async function confirm () { async function confirm () {
state.isLoading = true state.isLoading = true
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.post(`api-keys/${props.apiKey.id}/revoke`).json()
mutation: ` if (!resp?.ok) {
mutation revokeApiKey ($id: UUID!) { throw new Error(resp?.message || 'An unexpected error occured.')
revokeApiKey (id: $id) {
operation {
succeeded
message
} }
}
}
`,
variables: {
id: props.apiKey.id
}
})
if (resp?.data?.revokeApiKey?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.api.revokeSuccess') message: t('admin.api.revokeSuccess')
}) })
onDialogOK() onDialogOK()
} else {
throw new Error(resp?.data?.revokeApiKey?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
// -> ky throws above 400 — a key revoked from another tab answers 409
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
} }
state.isLoading = false state.isLoading = false

@ -71,34 +71,21 @@ const state = reactive({
async function confirm () { async function confirm () {
state.isLoading = true state.isLoading = true
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.delete(`hooks/${props.hook.id}`)
mutation: ` if (!resp?.ok) {
mutation deleteHook ($id: UUID!) { throw new Error((await resp.json())?.message || 'An unexpected error occured.')
deleteHook(id: $id) {
operation {
succeeded
message
} }
}
}
`,
variables: {
id: props.hook.id
}
})
if (resp?.data?.deleteHook?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.webhooks.deleteSuccess') message: t('admin.webhooks.deleteSuccess')
}) })
onDialogOK() onDialogOK()
} else {
throw new Error(resp?.data?.deleteHook?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
// -> ky throws above 400 — a webhook deleted from another tab answers 404
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
} }
state.isLoading = false state.isLoading = false

@ -88,6 +88,8 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
) {{opt.type}} ) {{opt.type}}
q-item-section q-item-section
q-item-label {{opt.name}} q-item-label {{opt.name}}
//- Subscribing is allowed, but say plainly that nothing fires it yet
q-item-label(caption, v-if='!opt.isEmitted') {{ t('admin.webhooks.eventNotEmitted') }}
q-item q-item
blueprint-icon.self-start(icon='unknown-status') blueprint-icon.self-start(icon='unknown-status')
q-item-section q-item-section
@ -194,7 +196,6 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
<script setup> <script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar' import { useDialogPluginComponent, useQuasar } from 'quasar'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
@ -227,6 +228,8 @@ const { t } = useI18n()
const state = reactive({ const state = reactive({
isLoading: false, isLoading: false,
/** Event keys the server actually emits. Null until fetched, i.e. assume all of them. */
emittedEvents: null,
hook: { hook: {
name: '', name: '',
events: [], events: [],
@ -242,7 +245,7 @@ const state = reactive({
// COMPUTED // COMPUTED
const events = computed(() => ([ const EVENT_DEFINITIONS = computed(() => ([
{ key: 'page:create', name: t('admin.webhooks.eventCreatePage'), type: t('admin.webhooks.typePage') }, { key: 'page:create', name: t('admin.webhooks.eventCreatePage'), type: t('admin.webhooks.typePage') },
{ key: 'page:edit', name: t('admin.webhooks.eventEditPage'), type: t('admin.webhooks.typePage') }, { key: 'page:edit', name: t('admin.webhooks.eventEditPage'), type: t('admin.webhooks.typePage') },
{ key: 'page:rename', name: t('admin.webhooks.eventRenamePage'), type: t('admin.webhooks.typePage') }, { key: 'page:rename', name: t('admin.webhooks.eventRenamePage'), type: t('admin.webhooks.typePage') },
@ -259,6 +262,11 @@ const events = computed(() => ([
{ key: 'user:logout', name: t('admin.webhooks.eventUserLogout'), type: t('admin.webhooks.typeUser') } { key: 'user:logout', name: t('admin.webhooks.eventUserLogout'), type: t('admin.webhooks.typeUser') }
])) ]))
const events = computed(() => EVENT_DEFINITIONS.value.map(evt => ({
...evt,
isEmitted: state.emittedEvents === null || state.emittedEvents.includes(evt.key)
})))
// REFS // REFS
const editWebhookForm = ref(null) const editWebhookForm = ref(null)
@ -279,41 +287,38 @@ const hookUrlValidation = [
// METHODS // METHODS
/** The fields the API accepts — `state` and `lastErrorMessage` are the server's to set, not ours. */
function writableFields () {
return {
name: state.hook.name,
events: state.hook.events,
url: state.hook.url,
includeMetadata: state.hook.includeMetadata,
includeContent: state.hook.includeContent,
acceptUntrusted: state.hook.acceptUntrusted,
authHeader: state.hook.authHeader ?? ''
}
}
async function fetchHook (id) { async function fetchHook (id) {
state.isLoading = true state.isLoading = true
try { try {
const resp = await APOLLO_CLIENT.query({ const resp = await API_CLIENT.get(`hooks/${id}`).json()
query: ` if (!resp?.id) {
query getHook ( throw new Error(t('admin.webhooks.loadFailed'))
$id: UUID!
) {
hookById (
id: $id
) {
name
events
url
includeMetadata
includeContent
acceptUntrusted
authHeader
state
lastErrorMessage
}
} }
`, // -> Merged onto the defaults so a null column (e.g. no auth header) still binds to an input
fetchPolicy: 'no-cache', state.hook = {
variables: { id } ...state.hook,
}) ...resp,
if (resp?.data?.hookById) { authHeader: resp.authHeader ?? '',
state.hook = cloneDeep(resp.data.hookById) lastErrorMessage: resp.lastErrorMessage ?? ''
} else {
throw new Error('Failed to fetch webhook configuration.')
} }
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
onDialogHide() onDialogHide()
} }
@ -327,48 +332,20 @@ async function create () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('admin.webhooks.createInvalidData')) throw new Error(t('admin.webhooks.createInvalidData'))
} }
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.post('hooks', { json: writableFields() }).json()
mutation: ` if (!resp?.ok) {
mutation createHook ( throw new Error(resp?.message || 'An unexpected error occured.')
$name: String!
$events: [String]!
$url: String!
$includeMetadata: Boolean!
$includeContent: Boolean!
$acceptUntrusted: Boolean!
$authHeader: String
) {
createHook (
name: $name
events: $events
url: $url
includeMetadata: $includeMetadata
includeContent: $includeContent
acceptUntrusted: $acceptUntrusted
authHeader: $authHeader
) {
operation {
succeeded
message
}
} }
}
`,
variables: state.hook
})
if (resp?.data?.createHook?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.webhooks.createSuccess') message: t('admin.webhooks.createSuccess')
}) })
onDialogOK() onDialogOK()
} else {
throw new Error(resp?.data?.createHook?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
} }
state.isLoading = false state.isLoading = false
@ -381,57 +358,39 @@ async function save () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('admin.webhooks.createInvalidData')) throw new Error(t('admin.webhooks.createInvalidData'))
} }
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put(`hooks/${props.hookId}`, { json: writableFields() }).json()
mutation: ` if (!resp?.ok) {
mutation saveHook ( throw new Error(resp?.message || 'An unexpected error occured.')
$id: UUID!
$patch: HookUpdateInput!
) {
updateHook (
id: $id
patch: $patch
) {
operation {
succeeded
message
} }
}
}
`,
variables: {
id: props.hookId,
patch: {
name: state.hook.name,
events: state.hook.events,
url: state.hook.url,
acceptUntrusted: state.hook.acceptUntrusted,
authHeader: state.hook.authHeader,
includeMetadata: state.hook.includeMetadata,
includeContent: state.hook.includeContent
}
}
})
if (resp?.data?.updateHook?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.webhooks.updateSuccess') message: t('admin.webhooks.updateSuccess')
}) })
onDialogOK() onDialogOK()
} else {
throw new Error(resp?.data?.updateHook?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: apiMessage || err.message
}) })
} }
state.isLoading = false state.isLoading = false
} }
async function fetchEmittedEvents () {
try {
const resp = await API_CLIENT.get('hooks/events').json()
state.emittedEvents = (resp ?? []).filter(evt => evt.isEmitted).map(evt => evt.key)
} catch {
// -> Purely informational: on failure, flag nothing rather than flag everything
state.emittedEvents = null
}
}
// MOUNTED // MOUNTED
onMounted(() => { onMounted(() => {
fetchEmittedEvents()
if (props.hookId) { if (props.hookId) {
fetchHook(props.hookId) fetchHook(props.hookId)
} }

@ -67,14 +67,18 @@ q-page.admin-api
q-list(separator) q-list(separator)
q-item(v-for='key of state.keys', :key='key.id') q-item(v-for='key of state.keys', :key='key.id')
q-item-section(side) q-item-section(side)
q-icon(name='las la-key', :color='key.isRevoked ? `negative` : `positive`') q-icon(name='las la-key', :color='isUsable(key) ? `positive` : `negative`')
q-item-section q-item-section
q-item-label {{key.name}} q-item-label {{key.name}}
q-item-label(caption) Ending in {{key.keyShort}} q-item-label(caption) {{ t('admin.api.keyEndingIn', { suffix: key.keyShort }) }}
q-item-label(caption) Created On: #[strong {{DateTime.fromISO(key.createdAt).toFormat('fff')}}] q-item-label(caption) {{ t('admin.api.permissionsFrom', { groups: groupNames(key) }) }}
q-item-label(caption) Expiration: #[strong(:style='key.isRevoked ? `text-decoration: line-through;` : ``') {{DateTime.fromISO(key.expiration).toFormat('fff')}}] q-item-label(caption) {{ t('admin.api.createdOn', { date: humanizeDate(key.createdAt) }) }}
q-item-label(caption)
span(:style='key.isRevoked ? `text-decoration: line-through;` : ``')
| {{ t('admin.api.expiresOn', { date: humanizeDate(key.expiration) }) }}
//- Revoked wins over expired: it is the state an operator acted on
q-item-section( q-item-section(
v-if='key.isRevoked' v-if='key.isRevoked || isExpired(key)'
side side
style='flex-direction: row; align-items: center;' style='flex-direction: row; align-items: center;'
) )
@ -83,26 +87,26 @@ q-page.admin-api
size='xs' size='xs'
name='las la-exclamation-triangle' name='las la-exclamation-triangle'
) )
.text-caption.text-negative {{t('admin.api.revoked')}} .text-caption.text-negative {{ key.isRevoked ? t('admin.api.revoked') : t('admin.api.expired') }}
q-tooltip(anchor='center left', self='center right') {{t('admin.api.revokedHint')}} q-tooltip(anchor='center left', self='center right') {{ key.isRevoked ? t('admin.api.revokedHint') : t('admin.api.expiredHint') }}
q-separator.q-ml-md(vertical) q-separator.q-ml-md(vertical)
q-item-section(side, style='flex-direction: row; align-items: center;') q-item-section(side, style='flex-direction: row; align-items: center;')
q-btn.acrylic-btn( q-btn.acrylic-btn(
:color='key.isRevoked ? `gray` : `red`' :color='key.isRevoked ? `gray` : `red`'
icon='las la-ban' icon='las la-ban'
flat flat
:aria-label='t(`admin.api.revoke`)'
@click='revoke(key)' @click='revoke(key)'
:disable='key.isRevoked' :disable='key.isRevoked'
) )
q-tooltip(v-if='!key.isRevoked', anchor='center left', self='center right') {{ t('admin.api.revoke') }}
</template> </template>
<script setup> <script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue' import { onMounted, reactive } from 'vue'
import { DateTime } from 'luxon'
import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue' import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue'
import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue' import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue'
@ -136,37 +140,61 @@ const state = reactive({
loading: 0, loading: 0,
isToggleLoading: false, isToggleLoading: false,
keys: [], keys: [],
isCreateDialogShown: false, groups: []
isRevokeConfirmDialogShown: false,
revokeLoading: false,
current: {}
}) })
// METHODS // METHODS
function humanizeDate (val) {
if (!val) { return '---' }
return Temporal.Instant.from(val).toLocaleString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
})
}
/** A key past its expiration still authenticates nothing, even though it was never revoked. */
function isExpired (key) {
return Temporal.Instant.compare(Temporal.Instant.from(key.expiration), Temporal.Now.instant()) <= 0
}
function isUsable (key) {
return !key.isRevoked && !isExpired(key)
}
/** Group names rather than IDs, falling back to the ID for a group that has since been deleted. */
function groupNames (key) {
return (key.groups ?? [])
.map(id => state.groups.find(g => g.id === id)?.name ?? id)
.join(', ')
}
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ try {
query: ` // -> Groups are fetched alongside the keys so the list can name the permissions each key carries
query getApiKeys { const [keys, apiState, groups] = await Promise.all([
apiKeys { API_CLIENT.get('api-keys').json(),
id API_CLIENT.get('system/api').json(),
name API_CLIENT.get('groups').json()
keyShort ])
expiration state.keys = keys ?? []
isRevoked state.groups = groups ?? []
createdAt state.enabled = apiState?.isEnabled === true
updatedAt // -> Keeps the status light in the admin sidebar in step without another round trip
}
apiState
}
`,
fetchPolicy: 'network-only'
})
state.keys = cloneDeep(resp?.data?.apiKeys) ?? []
state.enabled = resp?.data?.apiState === true
adminStore.info.isApiEnabled = state.enabled adminStore.info.isApiEnabled = state.enabled
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.api.loadFailed'),
caption: err.message
})
}
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }
@ -181,36 +209,25 @@ async function refresh () {
async function globalSwitch () { async function globalSwitch () {
state.isToggleLoading = true state.isToggleLoading = true
const wanted = !state.enabled
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put('system/api', {
mutation: ` json: { isEnabled: wanted }
mutation ($enabled: Boolean!) { }).json()
setApiState (enabled: $enabled) { if (!resp?.ok) {
operation { throw new Error(resp?.message || 'An unexpected error occurred.')
succeeded
message
} }
}
}
`,
variables: {
enabled: !state.enabled
}
})
if (resp?.data?.setApiState?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: state.enabled ? t('admin.api.toggleStateDisabledSuccess') : t('admin.api.toggleStateEnabledSuccess') message: wanted ? t('admin.api.toggleStateEnabledSuccess') : t('admin.api.toggleStateDisabledSuccess')
}) })
await load() await load()
} else {
throw new Error(resp?.data?.setApiState?.operation?.message || 'An unexpected error occurred.')
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to switch API state.', message: t('admin.api.toggleStateFailed'),
caption: err.message caption: apiMessage || err.message
}) })
} }
state.isToggleLoading = false state.isToggleLoading = false

@ -1,5 +1,5 @@
<template lang='pug'> <template lang='pug'>
q-page.admin-mail q-page.admin-auth
.row.q-pa-md.items-center .row.q-pa-md.items-center
.col-auto .col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-security-lock.svg') img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-security-lock.svg')
@ -17,6 +17,15 @@ q-page.admin-mail
type='a' type='a'
) )
q-tooltip {{ t(`common.actions.viewDocs`) }} q-tooltip {{ t(`common.actions.viewDocs`) }}
q-btn.q-mr-sm.acrylic-btn(
icon='las la-redo-alt'
flat
color='secondary'
:loading='state.loading > 0'
:aria-label='t(`common.actions.refresh`)'
@click='refresh'
)
q-tooltip {{ t(`common.actions.refresh`) }}
q-btn( q-btn(
unelevated unelevated
icon='mdi-check' icon='mdi-check'
@ -57,6 +66,10 @@ q-page.admin-mail
) )
q-menu(auto-close, fit, max-width='300px') q-menu(auto-close, fit, max-width='300px')
q-list(separator) q-list(separator)
//- Only the local module ships with the wiki so far, and it is already configured
q-item(v-if='availableStrategies.length < 1')
q-item-section
q-item-label(caption) {{ t('admin.auth.noModulesToAdd') }}
q-item( q-item(
v-for='str of availableStrategies' v-for='str of availableStrategies'
:key='str.key' :key='str.key'
@ -75,7 +88,7 @@ q-page.admin-mail
q-item-section q-item-section
q-item-label: strong {{ str.title }} q-item-label: strong {{ str.title }}
q-item-label(caption, lines='2') {{str.description}} q-item-label(caption, lines='2') {{str.description}}
.col .col(v-if='state.strategy.id')
q-card.q-pb-sm q-card.q-pb-sm
q-card-section q-card-section
.text-subtitle1 {{ t('admin.auth.info') }} .text-subtitle1 {{ t('admin.auth.info') }}
@ -98,11 +111,11 @@ q-page.admin-mail
q-item-section q-item-section
q-item-label {{ t(`admin.auth.enabled`) }} q-item-label {{ t(`admin.auth.enabled`) }}
q-item-label(caption) {{ t(`admin.auth.enabledHint`) }} q-item-label(caption) {{ t(`admin.auth.enabledHint`) }}
q-item-label.text-deep-orange(v-if='state.strategy.strategy.key === `local`', caption) {{ t(`admin.auth.enabledForced`) }} q-item-label.text-deep-orange(v-if='isBuiltInLocal', caption) {{ t(`admin.auth.enabledForced`) }}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='state.strategy.isEnabled' v-model='state.strategy.isEnabled'
:disable='state.strategy.strategy.key === `local`' :disable='isBuiltInLocal'
color='primary' color='primary'
checked-icon='las la-check' checked-icon='las la-check'
unchecked-icon='las la-times' unchecked-icon='las la-times'
@ -114,6 +127,9 @@ q-page.admin-mail
q-item-section q-item-section
q-item-label {{ t(`admin.auth.registration`) }} q-item-label {{ t(`admin.auth.registration`) }}
q-item-label(caption) {{ state.strategy.strategy.key === `local` ? t(`admin.auth.registrationLocalHint`) : t(`admin.auth.registrationHint`) }} q-item-label(caption) {{ state.strategy.strategy.key === `local` ? t(`admin.auth.registrationLocalHint`) : t(`admin.auth.registrationHint`) }}
//- Saved, but there is no self-registration path in the server yet — say so rather than
//- let the toggle read as a working setting
q-item-label.text-orange(caption) {{ t(`admin.auth.registrationNotEnforced`) }}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='state.strategy.registration' v-model='state.strategy.registration'
@ -203,11 +219,11 @@ q-page.admin-mail
v-if='configIfCheck(cfg.if)' v-if='configIfCheck(cfg.if)'
) )
q-separator.q-my-sm(inset, v-if='idx > 0') q-separator.q-my-sm(inset, v-if='idx > 0')
q-item(v-if='cfg.type === `boolean`', tag='label') q-item(v-if='cfg.type === `boolean`', :tag='cfg.readOnly ? `div` : `label`')
blueprint-icon(:icon='cfg.icon', :hue-rotate='cfg.readOnly ? -45 : 0') blueprint-icon(:icon='cfg.icon', :hue-rotate='cfg.readOnly ? -45 : 0')
q-item-section q-item-section
q-item-label {{ cfg.title }} q-item-label {{ cfg.title }}
q-item-label(caption) {{ cfg.hint }} q-item-label(:class='cfg.readOnly ? `text-orange` : ``', caption) {{ cfg.hint }}
q-item-section(avatar) q-item-section(avatar)
q-toggle( q-toggle(
v-model='cfg.value' v-model='cfg.value'
@ -221,7 +237,7 @@ q-page.admin-mail
blueprint-icon(:icon='cfg.icon', :hue-rotate='cfg.readOnly ? -45 : 0') blueprint-icon(:icon='cfg.icon', :hue-rotate='cfg.readOnly ? -45 : 0')
q-item-section q-item-section
q-item-label {{ cfg.title }} q-item-label {{ cfg.title }}
q-item-label(caption) {{ cfg.hint }} q-item-label(:class='cfg.readOnly ? `text-orange` : ``', caption) {{ cfg.hint }}
q-item-section( q-item-section(
:style='cfg.type === `number` ? `flex: 0 0 150px;` : ``' :style='cfg.type === `number` ? `flex: 0 0 150px;` : ``'
:class='{ "col-auto": cfg.enum && cfg.enumDisplay === `buttons` }' :class='{ "col-auto": cfg.enum && cfg.enumDisplay === `buttons` }'
@ -253,7 +269,7 @@ q-page.admin-mail
outlined outlined
v-model='cfg.value' v-model='cfg.value'
dense dense
:type='cfg.multiline ? `textarea` : (cfg.sensitive ? `password` : `input`)' :type='inputTypeFor(cfg)'
:aria-label='cfg.title' :aria-label='cfg.title'
:disable='cfg.readOnly' :disable='cfg.readOnly'
) )
@ -264,6 +280,7 @@ q-page.admin-mail
q-card.q-pb-sm.q-mt-md(v-if='strategyRefs.length > 0') q-card.q-pb-sm.q-mt-md(v-if='strategyRefs.length > 0')
q-card-section q-card-section
.text-subtitle1 {{ t('admin.auth.configReference') }} .text-subtitle1 {{ t('admin.auth.configReference') }}
.text-caption.text-grey {{ t('admin.auth.configReferenceSubtitle') }}
q-item(v-for='strRef of strategyRefs', :key='strRef.key') q-item(v-for='strRef of strategyRefs', :key='strRef.key')
blueprint-icon(:icon='strRef.icon', :hue-rotate='-45') blueprint-icon(:icon='strRef.icon', :hue-rotate='-45')
q-item-section q-item-section
@ -300,22 +317,19 @@ q-page.admin-mail
icon='las la-trash-alt' icon='las la-trash-alt'
flat flat
color='negative' color='negative'
:disable='state.strategy.strategy.key === `local`' :disable='isBuiltInLocal'
label='Delete Strategy' :label='t(`admin.auth.deleteStrategy`)'
@click='deleteStrategy(state.strategy.id)' @click='confirmDelete'
) )
q-tooltip(v-if='isBuiltInLocal') {{ t('admin.auth.deleteLocalForbidden') }}
</template> </template>
<script setup> <script setup>
import { cloneDeep, find, reject, transform } from 'lodash-es'
import { v4 as uuid } from 'uuid'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch, nextTick } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -325,7 +339,6 @@ const $q = useQuasar()
// STORES // STORES
const adminStore = useAdminStore()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
@ -339,6 +352,13 @@ useMeta({
title: t('admin.auth.title') title: t('admin.auth.title')
}) })
// CONSTANTS
const GUESTS_GROUP_ID = '10000000-0000-4000-8000-000000000001'
// -> The strategy every account's password is stored against, hence the one that cannot be disabled
// or deleted. A second instance of the local module is an ordinary strategy.
const BUILTIN_LOCAL_STRATEGY_ID = '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
// DATA // DATA
const state = reactive({ const state = reactive({
@ -348,7 +368,6 @@ const state = reactive({
strategies: [], strategies: [],
activeStrategies: [], activeStrategies: [],
selectedStrategy: '', selectedStrategy: '',
host: '',
strategy: { strategy: {
strategy: {} strategy: {}
} }
@ -356,6 +375,9 @@ const state = reactive({
// COMPUTED // COMPUTED
const isBuiltInLocal = computed(() => {
return state.strategy.id === BUILTIN_LOCAL_STRATEGY_ID
})
const availableStrategies = computed(() => { const availableStrategies = computed(() => {
return state.strategies.filter(str => str.key !== 'local') return state.strategies.filter(str => str.key !== 'local')
}) })
@ -364,308 +386,234 @@ const selectedGroupName = computed(() => {
}) })
const strategyRefs = computed(() => { const strategyRefs = computed(() => {
if (!state.selectedStrategy) { return [] } if (!state.selectedStrategy) { return [] }
const str = find(state.strategies, ['key', state.strategy?.strategy.key]) const str = state.strategies.find(s => s.key === state.strategy?.strategy?.key)
if (!str || !str.refs) { return [] } if (!str?.refs) { return [] }
return Object.entries(str.refs).map(([k, v]) => { return Object.entries(str.refs).map(([key, ref]) => {
return { return {
...v, ...ref,
key: k, key,
value: v.value.replaceAll('{host}', window.location.origin).replaceAll('{id}', state.selectedStrategy) value: ref.value.replaceAll('{host}', window.location.origin).replaceAll('{id}', state.selectedStrategy)
} }
}) ?? [] })
}) })
// WATCHERS // WATCHERS
watch(() => state.selectedStrategy, (newValue, oldValue) => { watch(() => state.selectedStrategy, (newValue) => {
state.strategy = find(state.activeStrategies, ['id', newValue]) || {} state.strategy = state.activeStrategies.find(str => str.id === newValue) || { strategy: {} }
}) })
watch(() => state.activeStrategies, (newValue, oldValue) => { watch(() => state.activeStrategies, (newValue) => {
state.selectedStrategy = newValue[0]?.id // -> Keep the current selection across a reload, falling back to the first strategy
state.selectedStrategy = newValue.some(str => str.id === state.selectedStrategy)
? state.selectedStrategy
: newValue[0]?.id
state.strategy = newValue.find(str => str.id === state.selectedStrategy) || { strategy: {} }
}) })
// METHODS // METHODS
async function loadGroups () { /**
state.loading++ * Turn a module prop declaration and its stored value into the shape the config editor renders,
state.loadingGroups = true * expanding `value|label` enum entries into options.
const resp = await APOLLO_CLIENT.query({ */
query: ` function buildConfigEditor (props, values) {
query getGroupsForAdminAuth { const config = {}
groups { for (const [key, prop] of Object.entries(props ?? {})) {
id config[key] = {
name ...prop,
value: values?.[key] ?? prop.default,
...prop.enum && {
enum: prop.enum.map(entry => {
const [value, label] = entry.split('|')
return { value, label: label ?? value }
})
} }
} }
`, }
fetchPolicy: 'network-only' return config
}) }
state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [])
state.loadingGroups = false function inputTypeFor (cfg) {
state.loading-- if (cfg.multiline) { return 'textarea' }
if (cfg.sensitive) { return 'password' }
return cfg.type === 'number' ? 'number' : 'text'
} }
async function load () { async function load () {
state.loading++ state.loading++
state.loadingGroups = true
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ try {
query: ` const [modules, strategies, groups] = await Promise.all([
query adminFetchAuthStrategies { API_CLIENT.get('authentication/modules').json(),
authStrategies { API_CLIENT.get('authentication/strategies').json(),
key API_CLIENT.get('groups').json()
props ])
refs state.strategies = modules ?? []
title state.activeStrategies = (strategies ?? []).map(str => {
description const mod = state.strategies.find(m => m.key === str.module) ?? { key: str.module, title: str.module }
isAvailable
useForm
usernameType
logo
color
vendor
website
icon
}
authActiveStrategies {
id
strategy {
key
}
displayName
isEnabled
config
registration
allowedEmailRegex
autoEnrollGroups
}
}
`,
fetchPolicy: 'network-only'
})
state.strategies = resp?.data?.authStrategies || []
state.activeStrategies = (cloneDeep(resp?.data?.authActiveStrategies) || []).map(a => {
const str = cloneDeep(find(state.strategies, ['key', a.strategy.key])) || {}
a.strategy = str
a.config = transform(str.props, (r, v, k) => {
r[k] = {
...v,
value: a.config?.[k],
...v.enum && {
enum: v.enum.map(o => {
if (o.indexOf('|') > 0) {
const oParsed = o.split('|')
return {
value: oParsed[0],
label: oParsed[1]
}
} else {
return { return {
value: o, ...str,
label: o strategy: mod,
} config: buildConfigEditor(mod.props, str.config)
} }
}) })
} // -> Guests cannot be enrolled into, being the group of users who never logged in
} state.groups = (groups ?? []).filter(g => g.id !== GUESTS_GROUP_ID)
}, {}) } catch (err) {
return a $q.notify({
type: 'negative',
message: t('admin.auth.loadFailed'),
caption: err.message
}) })
}
state.loadingGroups = false
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }
async function refresh () {
await load()
$q.notify({
type: 'positive',
message: t('admin.auth.refreshSuccess')
})
}
function configIfCheck (ifs) { function configIfCheck (ifs) {
if (!ifs || ifs.length < 1) { return true } if (!ifs || ifs.length < 1) { return true }
return ifs.every(s => state.strategy.config[s.key]?.value === s.eq) return ifs.every(s => state.strategy.config[s.key]?.value === s.eq)
} }
function addStrategy (str) { /**
const newStr = { * The strategy as the API expects it. Read-only props are left out: the server keeps whatever is
id: uuid(), * stored for them, so sending them back would be pretending they can be set.
strategy: str, */
config: transform(str.props, (r, v, k) => { function payloadFor (str) {
r[k] = { const config = {}
...v, for (const [key, cfg] of Object.entries(str.config ?? {})) {
value: v.default, if (cfg.readOnly) { continue }
...v.enum && { config[key] = cfg.type === 'number' ? Number(cfg.value) : cfg.value
enum: v.enum.map(o => {
if (o.indexOf('|') > 0) {
const oParsed = o.split('|')
return {
value: oParsed[0],
label: oParsed[1]
} }
} else {
return { return {
value: o, displayName: str.displayName,
label: o isEnabled: str.isEnabled,
} registration: str.registration,
} allowedEmailRegex: str.allowedEmailRegex ?? '',
}) autoEnrollGroups: str.autoEnrollGroups ?? [],
} config
}
}, {}),
isEnabled: true,
displayName: str.title,
registration: true,
allowedEmailRegex: '',
autoEnrollGroups: []
} }
state.activeStrategies = [...state.activeStrategies, newStr]
nextTick(() => {
state.selectedStrategy = newStr.id
})
} }
function deleteStrategy (id) { /**
state.activeStrategies = reject(state.activeStrategies, ['id', id]) * Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage (err) {
return err.response?.json().then(b => b?.message).catch(() => null) ?? err.message
} }
async function save () { async function save () {
if (state.loading > 0) { return }
state.loading++ state.loading++
const failures = []
for (const str of state.activeStrategies) {
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put(`authentication/strategies/${str.id}`, {
mutation: ` json: payloadFor(str)
mutation($strategies: [AuthenticationStrategyInput]!) { }).json()
authentication { if (!resp?.ok) {
updateStrategies(strategies: $strategies) { throw new Error(resp?.message || 'An unexpected error occured.')
responseResult {
succeeded
errorCode
slug
message
}
}
} }
} catch (err) {
failures.push({ name: str.displayName, message: await apiMessage(err) })
} }
`,
variables: {
strategies: this.activeStrategies.map((str, idx) => ({
key: str.key,
strategyKey: str.strategy.key,
displayName: str.displayName,
order: idx,
isEnabled: str.isEnabled,
config: str.config.map(cfg => ({ ...cfg, value: JSON.stringify({ v: cfg.value.value }) })),
selfRegistration: str.selfRegistration,
domainWhitelist: str.domainWhitelist,
autoEnrollGroups: str.autoEnrollGroups
}))
} }
if (failures.length > 0) {
for (const failure of failures) {
$q.notify({
type: 'negative',
message: t('admin.auth.saveFailed', { strategy: failure.name }),
caption: failure.message
}) })
if (resp?.data?.authentication?.updateStrategies?.operation.succeeded) { }
} else {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.auth.saveSuccess') message: t('admin.auth.saveSuccess')
}) })
} else {
throw new Error(resp?.data?.authentication?.updateStrategies?.operation?.message || t('common.error.unexpected'))
} }
state.loading--
await load()
}
async function addStrategy (mod) {
state.loading++
try {
const resp = await API_CLIENT.post('authentication/strategies', {
json: { module: mod.key, displayName: mod.title }
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
$q.notify({
type: 'positive',
message: t('admin.auth.addSuccess', { strategy: mod.title })
})
state.selectedStrategy = resp.id
} catch (err) { } catch (err) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to save site theme config', message: t('admin.auth.addFailed'),
caption: err.message caption: await apiMessage(err)
}) })
} }
state.loading-- state.loading--
await load()
} }
// apollo: { function confirmDelete () {
// strategies: { const strategy = state.strategy
// query: ` $q.dialog({
// query { title: t('admin.auth.deleteStrategy'),
// authentication { message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }),
// strategies { persistent: true,
// key ok: {
// title label: t('common.actions.delete'),
// description color: 'negative',
// isAvailable unelevated: true
// useForm },
// logo cancel: {
// website label: t('common.actions.cancel'),
// props { color: 'grey',
// key flat: true
// value }
// } }).onOk(async () => {
// } state.loading++
// } try {
// } const resp = await API_CLIENT.delete(`authentication/strategies/${strategy.id}`)
// `, if (!resp?.ok) {
// skip: true, throw new Error((await resp.json())?.message || 'An unexpected error occured.')
// fetchPolicy: 'network-only', }
// update: (data) => _.get(data, 'authentication.strategies', []).map(str => ({ $q.notify({
// ...str, type: 'positive',
// isDisabled: !str.isAvailable || str.key === 'local', message: t('admin.auth.deleteSuccess', { strategy: strategy.displayName })
// props: _.sortBy(str.props.map(cfg => ({ })
// key: cfg.key, } catch (err) {
// ...JSON.parse(cfg.value) $q.notify({
// })), [t => t.order]) type: 'negative',
// })), message: t('admin.auth.deleteFailed'),
// watchLoading (isLoading) { caption: await apiMessage(err)
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-strategies-refresh') })
// } }
// }, state.loading--
// activeStrategies: { await load()
// query: ` })
// query { }
// authentication {
// activeStrategies { // MOUNTED
// key
// strategy { onMounted(load)
// key
// title
// description
// useForm
// logo
// website
// }
// config {
// key
// value
// }
// order
// isEnabled
// displayName
// selfRegistration
// domainWhitelist
// autoEnrollGroups
// }
// }
// }
// `,
// skip: true,
// fetchPolicy: 'network-only',
// update: (data) => _.sortBy(_.get(data, 'authentication.activeStrategies', []).map(str => ({
// ...str,
// config: _.sortBy(str.config.map(cfg => ({
// ...cfg,
// value: JSON.parse(cfg.value)
// })), [t => t.value.order])
// })), ['order']),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-activestrategies-refresh')
// }
// },
// groups: {
// query: `{ test }`,
// fetchPolicy: 'network-only',
// update: (data) => data.groups.list,
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh')
// }
// },
// host: {
// query: `{ test }`,
// fetchPolicy: 'network-only',
// update: (data) => _.cloneDeep(data.site.config.host),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-host-refresh')
// }
// }
onMounted(() => {
load()
loadGroups()
})
</script> </script>

@ -39,6 +39,8 @@ q-page.admin-extensions
q-item-section q-item-section
q-item-label {{ext.title}} q-item-label {{ext.title}}
q-item-label(caption) {{ext.description}} q-item-label(caption) {{ext.description}}
q-item-label(caption, v-if='ext.website')
a.text-primary(:href='ext.website', target='_blank', rel='noopener') {{ ext.website }}
q-item-section(side) q-item-section(side)
.row .row
q-btn-group(unelevated) q-btn-group(unelevated)
@ -103,12 +105,10 @@ q-page.admin-extensions
<script setup> <script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue' import { onMounted, reactive } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// QUASAR // QUASAR
@ -117,7 +117,6 @@ const $q = useQuasar()
// STORES // STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -133,7 +132,7 @@ useMeta({
// DATA // DATA
const state = reactive({ const state = reactive({
loading: false, loading: 0,
extensions: [] extensions: []
}) })
@ -142,22 +141,15 @@ const state = reactive({
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ try {
query: ` state.extensions = await API_CLIENT.get('system/extensions').json() ?? []
query fetchExtensions { } catch (err) {
systemExtensions { $q.notify({
key type: 'negative',
title message: t('admin.extensions.loadFailed'),
description caption: err.message
isInstalled
isInstallable
isCompatible
}
}
`,
fetchPolicy: 'network-only'
}) })
state.extensions = cloneDeep(resp?.data?.systemExtensions) }
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }
@ -168,40 +160,23 @@ async function install (ext) {
html: true html: true
}) })
try { try {
const respRaw = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.post(`system/extensions/${ext.key}/install`).json()
mutation: ` if (!resp?.ok) {
mutation installExtension ( throw new Error(resp?.message || 'An unexpected error occured')
$key: String!
) {
installExtension (
key: $key
) {
operation {
succeeded
message
}
}
}
`,
variables: {
key: ext.key
} }
})
if (respRaw.data?.installExtension?.operation?.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.extensions.installSuccess') message: t('admin.extensions.installSuccess')
}) })
ext.isInstalled = true // -> Re-detect rather than assume: the install is only done once the server can see the tool
// this.$forceUpdate() await load()
} else {
throw new Error(respRaw.data?.installExtension?.operation?.message || 'An unexpected error occured')
}
} catch (err) { } catch (err) {
// -> ky throws above 400 — an extension that must be installed by hand answers 409 saying so
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: t('admin.extensions.installFailed'), message: t('admin.extensions.installFailed'),
caption: err.message caption: apiMessage || err.message
}) })
} }
$q.loading.hide() $q.loading.hide()

@ -88,12 +88,21 @@ q-page.admin-flags
unchecked-icon='las la-times' unchecked-icon='las la-times'
:aria-label='t(`admin.flags.sqlLog.label`)' :aria-label='t(`admin.flags.sqlLog.label`)'
) )
q-separator.q-my-sm(inset)
q-item
q-item-section(avatar)
q-icon(name='las la-info-circle', color='grey')
q-item-section
q-item-label(caption) {{t(`admin.flags.serverLogNotice`)}}
q-card.q-py-sm.q-mt-md q-card.q-py-sm.q-mt-md
q-item q-item
blueprint-icon(icon='administrative-tools') blueprint-icon(icon='administrative-tools')
q-item-section q-item-section
q-item-label {{t(`admin.flags.advanced.label`)}} q-item-label {{t(`admin.flags.advanced.label`)}}
q-item-label(caption) {{t(`admin.flags.advanced.hint`)}} q-item-label(caption) {{t(`admin.flags.advanced.hint`)}}
//- The editor was never built, and nothing reads custom keys — say so rather than leave
//- a disabled button with no explanation
q-item-label.text-orange(caption) {{t(`admin.flags.advanced.notImplemented`)}}
q-item-section(avatar) q-item-section(avatar)
q-btn( q-btn(
:label='t(`common.actions.edit`)' :label='t(`common.actions.edit`)'
@ -104,22 +113,6 @@ q-page.admin-flags
disabled disabled
) )
q-card.q-py-sm.q-mt-md
q-item
blueprint-icon(icon='key')
q-item-section
q-item-label {{t(`admin.flags.getTokenLabel`)}}
q-item-label(caption) {{t(`admin.flags.getTokenHint`)}}
q-item-section(avatar)
q-btn(
ref='copyTokenBtn'
:label='t(`common.actions.copy`)'
unelevated
icon='las la-clipboard'
color='primary'
text-color='white'
)
.col-12.col-lg-5.gt-md .col-12.col-lg-5.gt-md
.q-pa-md.text-center .q-pa-md.text-center
img(src='/_assets/illustrations/undraw_settings.svg', style='width: 80%;') img(src='/_assets/illustrations/undraw_settings.svg', style='width: 80%;')
@ -127,15 +120,13 @@ q-page.admin-flags
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive } from 'vue'
import { cloneDeep, omit } from 'lodash-es' import { omit } from 'es-toolkit/object'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ClipboardJS from 'clipboard'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { useUserStore } from '@/stores/user'
// QUASAR // QUASAR
@ -145,7 +136,6 @@ const $q = useQuasar()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N // I18N
@ -168,17 +158,22 @@ const state = reactive({
} }
}) })
// REFS
const copyTokenBtn = ref(null)
// METHODS // METHODS
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
try {
// -> Through the store, so that `experimental` is refreshed for the whole app at the same time
await flagsStore.load() await flagsStore.load()
state.flags = omit(cloneDeep(flagsStore.$state), ['loaded']) state.flags = omit(flagsStore.$state, ['loaded'])
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.flags.loadFailed'),
caption: err.message
})
}
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }
@ -188,38 +183,24 @@ async function save () {
state.loading++ state.loading++
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put('system/flags', {
mutation: ` json: state.flags
mutation updateFlags ( }).json()
$flags: JSON! if (!resp?.ok) {
) { throw new Error(resp?.message || 'An unexpected error occured.')
updateSystemFlags(
flags: $flags
) {
operation {
succeeded
message
}
}
}
`,
variables: {
flags: state.flags
} }
})
if (resp?.data?.updateSystemFlags?.operation?.succeeded) {
load()
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.flags.saveSuccess') message: t('admin.flags.saveSuccess')
}) })
} else { await load()
throw new Error(resp?.data?.updateSystemFlags?.operation?.message || 'An unexpected error occured.')
}
} catch (err) { } catch (err) {
// -> ky doesn't throw on 400, so the API's own message is on the response
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: err.message message: t('admin.flags.saveFailed'),
caption: apiMessage || err.message
}) })
} }
state.loading-- state.loading--
@ -227,28 +208,7 @@ async function save () {
// MOUNTED // MOUNTED
onMounted(async () => { onMounted(load)
load()
const clip = new ClipboardJS(copyTokenBtn.value.$el, {
text: () => {
return userStore.token
}
})
clip.on('success', () => {
$q.notify({
type: 'positive',
message: 'Token copied successfully',
icon: 'las la-clipboard'
})
})
clip.on('error', () => {
$q.notify({
type: 'negative',
message: 'Failed to copy token'
})
})
})
</script> </script>

@ -82,13 +82,11 @@ q-page.admin-flags
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive } from 'vue'
import { cloneDeep, omit } from 'lodash-es'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useFlagsStore } from '@/stores/flags'
import UtilCodeEditor from '@/components/UtilCodeEditor.vue' import UtilCodeEditor from '@/components/UtilCodeEditor.vue'
@ -98,7 +96,6 @@ const $q = useQuasar()
// STORES // STORES
const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -108,7 +105,7 @@ const { t } = useI18n()
// META // META
useMeta({ useMeta({
title: t('admin.flags.title') title: t('admin.search.title')
}) })
// DATA // DATA
@ -116,9 +113,11 @@ useMeta({
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
rebuildLoading: false, rebuildLoading: false,
availableDictionaries: [],
config: { config: {
termHighlighting: false, termHighlighting: false,
dictOverrides: '' // -> The editor works on text; the API stores and returns an object
dictOverrides: '{}'
} }
}) })
@ -128,22 +127,16 @@ async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
try { try {
const resp = await APOLLO_CLIENT.query({ const resp = await API_CLIENT.get('system/search').json()
query: ` state.config = {
query getSearchConfig { termHighlighting: resp?.termHighlighting === true,
systemSearch { dictOverrides: JSON.stringify(resp?.dictOverrides ?? {}, null, 2)
termHighlighting
dictOverrides
}
} }
`, state.availableDictionaries = resp?.availableDictionaries ?? []
fetchPolicy: 'network-only'
})
state.config = cloneDeep(resp?.data?.systemSearch)
} catch (err) { } catch (err) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to load search config', message: t('admin.search.loadFailed'),
caption: err.message caption: err.message
}) })
} }
@ -154,40 +147,43 @@ async function load () {
async function save () { async function save () {
state.loading++ state.loading++
try { try {
const respRaw = await APOLLO_CLIENT.mutate({ let dictOverrides
mutation: ` try {
mutation saveSearchConfig ( dictOverrides = JSON.parse(state.config.dictOverrides || '{}')
$termHighlighting: Boolean } catch (err) {
$dictOverrides: String throw new Error(t('admin.search.dictOverridesInvalidJSON', { reason: err.message }))
) {
updateSystemSearch(
termHighlighting: $termHighlighting
dictOverrides: $dictOverrides
) {
operation {
succeeded
slug
message
} }
if (typeof dictOverrides !== 'object' || Array.isArray(dictOverrides) || dictOverrides === null) {
throw new Error(t('admin.search.dictOverridesNotAnObject'))
} }
// -> Caught here rather than server-side so the offending entry can be named while the operator
// is still looking at the editor
for (const [locale, dictionary] of Object.entries(dictOverrides)) {
if (typeof dictionary !== 'string' || !state.availableDictionaries.includes(dictionary)) {
throw new Error(t('admin.search.dictOverridesUnknown', { locale, dictionary }))
}
}
const resp = await API_CLIENT.put('system/search', {
json: {
termHighlighting: state.config.termHighlighting,
dictOverrides
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
} }
`,
variables: state.config
})
const resp = respRaw?.data?.updateSystemSearch?.operation || {}
if (resp.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.search.saveSuccess') message: t('admin.search.saveSuccess')
}) })
} else { await load()
throw new Error(resp.message)
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to save search config', message: t('admin.search.saveFailed'),
caption: err.message caption: apiMessage || err.message
}) })
} }
state.loading-- state.loading--
@ -196,33 +192,20 @@ async function save () {
async function rebuild () { async function rebuild () {
state.rebuildLoading = true state.rebuildLoading = true
try { try {
const respRaw = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.post('system/search/rebuild').json()
mutation: ` if (!resp?.ok) {
mutation rebuildSearchIndex { throw new Error(resp?.message || 'An unexpected error occured.')
rebuildSearchIndex {
operation {
succeeded
slug
message
}
} }
}
`
})
const resp = respRaw?.data?.rebuildSearchIndex?.operation || {}
if (resp.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.search.rebuildInitSuccess') message: t('admin.search.rebuildInitSuccess')
}) })
} else {
throw new Error(resp.message)
}
} catch (err) { } catch (err) {
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to initiate a search index rebuild', message: t('admin.search.rebuildFailed'),
caption: err.message caption: apiMessage || err.message
}) })
} }
state.rebuildLoading = false state.rebuildLoading = false

@ -49,7 +49,10 @@ q-page.admin-mail
q-card-section.items-center(horizontal) q-card-section.items-center(horizontal)
q-card-section.col-auto.q-pr-none q-card-section.col-auto.q-pr-none
q-icon(name='las la-exclamation-triangle', size='sm') q-icon(name='las la-exclamation-triangle', size='sm')
q-card-section.text-caption {{ t('admin.security.warn') }} q-card-section.text-caption
div {{ t('admin.security.warn') }}
//- These are read when the HTTP server builds its plugin chain, not per request
div.q-mt-xs {{ t('admin.security.restartRequired') }}
q-item(tag='label', v-ripple) q-item(tag='label', v-ripple)
blueprint-icon(icon='rfid-signal') blueprint-icon(icon='rfid-signal')
q-item-section q-item-section
@ -185,7 +188,10 @@ q-page.admin-mail
q-card-section.items-center(horizontal) q-card-section.items-center(horizontal)
q-card-section.col-auto.q-pr-none q-card-section.col-auto.q-pr-none
q-icon(name='las la-info-circle', size='sm') q-icon(name='las la-info-circle', size='sm')
q-card-section.text-caption {{ t('admin.security.uploadsInfo') }} q-card-section.text-caption
div {{ t('admin.security.uploadsInfo') }}
//- Saved, but nothing reads them: there is no upload endpoint yet
div.q-mt-xs {{ t('admin.security.uploadsNotEnforced') }}
q-item q-item
blueprint-icon(icon='upload-to-the-cloud') blueprint-icon(icon='upload-to-the-cloud')
q-item-section q-item-section
@ -327,16 +333,13 @@ q-page.admin-mail
</template> </template>
<script setup> <script setup>
import { cloneDeep } from 'lodash-es'
import { filesize } from 'filesize' import { filesize } from 'filesize'
import filesizeParser from 'filesize-parser' import filesizeParser from 'filesize-parser'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { computed, onMounted, reactive, watch } from 'vue' import { onMounted, reactive } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// QUASAR // QUASAR
@ -345,7 +348,6 @@ const $q = useQuasar()
// STORES // STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -361,7 +363,7 @@ useMeta({
// DATA // DATA
const state = reactive({ const state = reactive({
loading: false, loading: 0,
config: { config: {
corsConfig: '', corsConfig: '',
corsMode: 'OFF', corsMode: 'OFF',
@ -406,35 +408,17 @@ const corsModes = [
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ try {
query: ` const resp = await API_CLIENT.get('system/security').json()
query getSecurityConfig { state.config = { ...state.config, ...resp }
systemSecurity {
authJwtAudience
authJwtExpiration
authJwtRenewablePeriod
corsConfig
corsMode
cspDirectives
disallowFloc
disallowIframe
disallowOpenRedirect
enforceCsp
enforceHsts
enforceSameOriginReferrerPolicy
forceAssetDownload
hstsDuration
trustProxy
uploadMaxFileSize
uploadMaxFiles
uploadScanSVG
}
}
`,
fetchPolicy: 'network-only'
})
state.config = cloneDeep(resp?.data?.systemSecurity)
state.humanUploadMaxFileSize = filesize(state.config.uploadMaxFileSize ?? 0, { base: 2, standard: 'jedec' }) state.humanUploadMaxFileSize = filesize(state.config.uploadMaxFileSize ?? 0, { base: 2, standard: 'jedec' })
} catch (err) {
$q.notify({
type: 'negative',
message: t('admin.security.loadFailed'),
caption: err.message
})
}
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }
@ -442,71 +426,38 @@ async function load () {
async function save () { async function save () {
state.loading++ state.loading++
try { try {
const respRaw = await APOLLO_CLIENT.mutate({ let uploadMaxFileSize
mutation: ` try {
mutation saveSecurityConfig ( uploadMaxFileSize = filesizeParser(state.humanUploadMaxFileSize || '0')
$authJwtAudience: String } catch {
$authJwtExpiration: String throw new Error(t('admin.security.maxUploadSizeInvalid'))
$authJwtRenewablePeriod: String
$corsConfig: String
$corsMode: SystemSecurityCorsMode
$cspDirectives: String
$disallowFloc: Boolean
$disallowIframe: Boolean
$disallowOpenRedirect: Boolean
$enforceCsp: Boolean
$enforceHsts: Boolean
$enforceSameOriginReferrerPolicy: Boolean
$hstsDuration: Int
$trustProxy: Boolean
$uploadMaxFiles: Int
$uploadMaxFileSize: Int
) {
updateSystemSecurity(
authJwtAudience: $authJwtAudience
authJwtExpiration: $authJwtExpiration
authJwtRenewablePeriod: $authJwtRenewablePeriod
corsConfig: $corsConfig
corsMode: $corsMode
cspDirectives: $cspDirectives
disallowFloc: $disallowFloc
disallowIframe: $disallowIframe
disallowOpenRedirect: $disallowOpenRedirect
enforceCsp: $enforceCsp
enforceHsts: $enforceHsts
enforceSameOriginReferrerPolicy: $enforceSameOriginReferrerPolicy
hstsDuration: $hstsDuration
trustProxy: $trustProxy
uploadMaxFiles: $uploadMaxFiles
uploadMaxFileSize: $uploadMaxFileSize
) {
status {
succeeded
slug
message
}
} }
if (!(uploadMaxFileSize > 0)) {
throw new Error(t('admin.security.maxUploadSizeInvalid'))
} }
`,
variables: { const resp = await API_CLIENT.put('system/security', {
json: {
...state.config, ...state.config,
uploadMaxFileSize: filesizeParser(state.humanUploadMaxFileSize || '0') uploadMaxFileSize
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
} }
})
const resp = respRaw?.data?.updateSystemSecurity?.status || {}
if (resp.succeeded) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.security.saveSuccess') message: t('admin.security.saveSuccess')
}) })
} else { await load()
throw new Error(resp.message)
}
} catch (err) { } catch (err) {
// -> ky throws above 400 — the server rejects combinations that would store a setting doing
// nothing, e.g. enforcing a CSP with no directives
const apiMessage = await err.response?.json().then(b => b?.message).catch(() => null)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'Failed to save security config', message: t('admin.security.saveFailed'),
caption: err.message caption: apiMessage || err.message
}) })
} }
state.loading-- state.loading--

@ -96,9 +96,6 @@ q-page.admin-webhooks
</template> </template>
<script setup> <script setup>
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar' import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive } from 'vue' import { onMounted, reactive } from 'vue'
@ -138,20 +135,15 @@ const state = reactive({
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ try {
query: ` state.hooks = await API_CLIENT.get('hooks').json() ?? []
query getHooks { } catch (err) {
hooks { $q.notify({
id type: 'negative',
name message: t('admin.webhooks.loadFailed'),
url caption: err.message
state
}
}
`,
fetchPolicy: 'network-only'
}) })
state.hooks = cloneDeep(resp?.data?.hooks) ?? [] }
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }

@ -50,7 +50,7 @@ export const useAdminStore = defineStore('admin', {
this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0) this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0)
this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a') this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a')
this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a') this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a')
this.info.isApiEnabled = clone(resp?.apiState ?? false) this.info.isApiEnabled = clone(resp?.isApiEnabled ?? false)
this.info.isMetricsEnabled = clone(resp?.isMetricsEnabled ?? false) this.info.isMetricsEnabled = clone(resp?.isMetricsEnabled ?? false)
this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false) this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false)
this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false) this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false)

@ -1,16 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export const useFlagsStore = defineStore('flags', { export const useFlagsStore = defineStore('flags', {
state: () => ({ state: () => ({
loaded: false, loaded: false,
experimental: false // -> Declared rather than left to `$patch` to create, so that anything reading a flag before the
// first load sees `false` instead of `undefined`
experimental: false,
authDebug: false,
sqlLog: false
}), }),
getters: {}, getters: {},
actions: { actions: {
async load () { async load() {
try { try {
const systemFlags = await API_CLIENT.get('system/flags') const systemFlags = await API_CLIENT.get('system/flags').json()
if (systemFlags) { if (systemFlags) {
this.$patch({ this.$patch({
...systemFlags, ...systemFlags,

Loading…
Cancel
Save