You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/users.ts

1747 lines
53 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { UserPatch, UserProfilePatch } from '../models/users.ts'
interface UserUpdateBody {
name?: string
email?: string
isActive?: boolean
isVerified?: boolean
meta?: Record<string, any>
prefs?: Record<string, any>
groups?: string[]
auth?: Record<string, any>
}
/** How large an avatar upload may be, before any resizing. */
const avatarUploadLimit = 2 * 1024 * 1024
/**
* The user the session belongs to, or null when the request is not from a logged in user.
*
* The `/profile` routes are session-authenticated rather than permission-gated: every logged in user
* may read and change its own profile, and no permission expresses that.
*/
function sessionUserId(req: FastifyRequest): string | null {
return req.session?.authenticated && req.session.user?.id ? req.session.user.id : null
}
/**
* Who is asking, as the interface needs to know it: the account on the session and the group-wide
* permissions it holds, or nothing at all for a guest.
*
* Exported because `bootstrap` answers the same question as part of the one call an app load makes,
* and two versions of "who is this" would be one too many.
*/
export function whoAmI(req: FastifyRequest): Record<string, any> {
if (!req.session?.authenticated) {
return { authenticated: false }
}
return {
authenticated: true,
...req.session.user,
/*
The same list the route permission hook checks against — written onto the session at login from
the groups the user belongs to. Nothing is added for the interface's benefit: a control it shows
on a permission the session does not hold leads to a button that gets a 403 from the endpoint
behind it.
*/
permissions: req.session.permissions ?? []
}
}
/**
* Refuse a `manage:users` holder any change to a user who is protected by `manage:system`.
*
* `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset
* the password of, or delete a `manage:system` account can take the instance over through it. Only
* somebody who already holds `manage:system` may touch one.
*
* @returns The refusal to throw, or null when the caller may proceed
*/
async function systemUserGuard(req: FastifyRequest, userId: string): Promise<CustomError | null> {
if (WIKI.models.groups.holdsSystemPermission(req)) {
return null
}
if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) {
return null
}
return new CustomError(
'userSystemProtected',
'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.',
403
)
}
/**
* Whether self-service profile editing is enabled on the site being browsed.
*
* It is a per-site feature: an instance whose user data comes from an external identity provider turns
* it off. The site is resolved from the request hostname, which is how the admin flag is scoped; an
* unresolvable hostname leaves the feature at its default.
*/
async function isProfileEditable(req: FastifyRequest): Promise<boolean> {
const site = req.hostname
? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
: null
return !site || site.config?.features?.profile !== false
}
/**
* Users API Routes
*/
async function routes(app: FastifyInstance) {
// -> An avatar upload is the raw image rather than a multipart form: one file, no fields, and no
// dependency to add. Registered inside this plugin, so every other route keeps rejecting an
// image body outright.
app.addContentTypeParser(
[...imageMimeTypes],
{ parseAs: 'buffer', bodyLimit: avatarUploadLimit },
(req, body, done) => {
done(null, body)
}
)
app.get<{
Querystring: { page?: number; limit?: number; filter?: string; assignableToGroupId?: string }
}>(
'/',
{
config: {
permissions: ['read:users', 'manage:users']
},
schema: {
summary: 'List all users',
tags: ['Users'],
querystring: {
type: 'object',
properties: {
filter: {
type: 'string',
description: 'Matched against the user name and email, case-insensitively.',
maxLength: 255
},
assignableToGroupId: {
type: 'string',
format: 'uuid',
description:
'Keep only the users that may be assigned to this group, i.e. omit its current members and any system user. Intended for pickers offering users to assign.'
},
page: { type: 'integer', minimum: 1, default: 1 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
}
},
response: {
200: {
description: 'List of Users',
type: 'object',
properties: {
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: 'integer' },
users: {
type: 'array',
items: { $ref: 'UserCore#' }
}
}
}
}
}
},
async (req) => {
const page = req.query.page ?? 1
const limit = req.query.limit ?? 20
const { total, users } = await WIKI.models.users.getUsers({
filter: req.query.filter ?? '',
assignableToGroupId: req.query.assignableToGroupId ?? '',
page,
limit
})
return { page, limit, total, users }
}
)
/**
* RECENT LOGINS
*/
app.get<{ Querystring: { limit?: number } }>(
'/recent-logins',
{
config: {
// -> `access:admin`, not `read:users`: this answers a panel on the admin dashboard, which
// everyone who can open the admin area sees, and it is the same permission `system/info`
// fills the rest of that dashboard with. It is why the answer is identity plus a timestamp
// and nothing else -- the user list, and every account flag on it, still needs `read:users`.
permissions: ['access:admin']
},
schema: {
summary: 'List the most recent logins',
description:
'Who signed in last, most recent first. Accounts that have never logged in are left out rather than trailing the list, as are system accounts — nothing signs in as the guest.',
tags: ['Users'],
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 }
}
},
response: {
200: {
description: 'The most recent logins, newest first',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string' },
lastLoginAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
}
}
}
}
}
}
},
async (req) => {
return WIKI.models.users.getRecentLogins({ limit: req.query.limit ?? 10 })
}
)
app.get(
'/whoami',
{
schema: {
summary: 'Get currently logged in user info',
description:
'Includes the group-wide permissions of the session, which is what the interface hides its own controls by. Permissions ON A PAGE are a different question, answered by `pages/userPermissions`.\n\nThe app itself gets this from `bootstrap` on load, together with the site and the flags; this endpoint is what asks again once a login or a logout has changed the answer.',
tags: ['Users']
}
},
async (req, reply) => {
reply.preventCache()
return whoAmI(req)
}
)
/**
* GET OWN PROFILE
*/
app.get(
'/profile',
{
schema: {
summary: "Get the logged in user's own profile",
description:
'Returns the profile of the user the session belongs to, with the `meta` / `prefs` blobs flattened into plain fields.',
tags: ['Users'],
response: {
200: {
description: 'User profile',
type: 'object',
$ref: 'UserProfile#'
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
const profile = await WIKI.models.users.getProfile(userId)
if (!profile) {
// -> The session outlived the user it points at
return reply.unauthorized()
}
return profile
}
)
/**
* UPDATE OWN PROFILE
*/
app.put<{ Body: UserProfilePatch }>(
'/profile',
{
schema: {
summary: "Update the logged in user's own profile",
description:
'Updates any subset of the profile fields; omitted ones are left unchanged. Requires the current site to have the `profile` feature enabled. The email cannot be changed here, and neither can any field an administrator owns.',
tags: ['Users'],
body: {
$ref: 'UserProfileUpdate#'
},
response: {
200: {
description: 'Profile updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
profile: {
$ref: 'UserProfile#'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
}
// -> A bad time zone would break every date the user sees, and the list of valid zones is only
// known at runtime, so it cannot be expressed as a schema enum
if (req.body.timezone !== undefined && req.body.timezone !== '') {
if (!Intl.supportedValuesOf('timeZone').includes(req.body.timezone)) {
throw new CustomError(
'userProfileInvalidTimezone',
`Not a recognized IANA time zone: ${req.body.timezone}`
)
}
}
const patch: UserProfilePatch = {}
for (const key of [
'name',
'location',
'jobTitle',
'pronouns',
'timezone',
'dateFormat',
'timeFormat',
'appearance',
'cvd'
] as const) {
if (req.body[key] !== undefined) {
patch[key] = req.body[key]
}
}
if (Object.keys(patch).length < 1) {
throw new CustomError('userProfileEmpty', 'No profile fields provided to update.')
}
if (patch.name !== undefined && !/^[^<>"]+$/.test(patch.name)) {
throw new CustomError('userProfileInvalidName', 'Invalid User Name')
}
const profile = await WIKI.models.users.updateProfile(userId, patch)
if (!profile) {
return reply.unauthorized()
}
// -> The session carries a copy of the name and the preferences, which `/whoami` serves on
// every page load. Left alone, it would hand back the pre-save values.
req.session.user = {
...req.session.user!,
name: profile.name,
timezone: profile.timezone,
dateFormat: profile.dateFormat,
timeFormat: profile.timeFormat,
appearance: profile.appearance,
cvd: profile.cvd
}
return {
ok: true,
message: 'Profile updated successfully.',
profile
}
}
)
/**
* UPLOAD OWN AVATAR
*/
app.put(
'/profile/avatar',
{
schema: {
summary: "Replace the logged in user's own avatar",
description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires the current site to have the \`profile\` feature enabled.`,
tags: ['Users'],
consumes: [...imageMimeTypes],
response: {
200: {
description: 'Avatar uploaded successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
}
const data = req.body
if (!Buffer.isBuffer(data) || data.length < 1) {
throw new CustomError('userAvatarEmpty', 'No image was sent.')
}
// -> The declared content type got the request this far; what the bytes actually are is what
// decides, since they are what gets stored and served back
if (!detectImageMime(data)) {
throw new CustomError(
'userAvatarInvalidImage',
'Not a PNG, JPEG, WebP or GIF image, whatever the request said it was.'
)
}
await WIKI.models.users.setAvatar(userId, data)
// -> The account menu reads `hasAvatar` off the session on every page load
req.session.user = { ...req.session.user!, hasAvatar: true }
return {
ok: true,
message: 'Avatar uploaded successfully.'
}
}
)
/**
* CLEAR OWN AVATAR
*/
app.delete(
'/profile/avatar',
{
schema: {
summary: "Remove the logged in user's own avatar",
description:
'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires the current site to have the `profile` feature enabled.',
tags: ['Users'],
response: {
200: {
description: 'Avatar cleared successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
if (!(await isProfileEditable(req))) {
return reply.forbidden('Profile editing is disabled on this site.')
}
await WIKI.models.users.clearAvatar(userId)
req.session.user = { ...req.session.user!, hasAvatar: false }
return {
ok: true,
message: 'Avatar cleared successfully.'
}
}
)
/**
* GET OWN GROUPS
*
* A user may see which groups it belongs to without holding `read:groups`, which would expose every
* group on the instance.
*/
app.get(
'/profile/groups',
{
schema: {
summary: 'Get the groups the logged in user belongs to',
description:
'Only the identity of each group. Reading what a group grants requires `read:groups`.',
tags: ['Users'],
response: {
200: {
description: 'Groups the user belongs to',
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
name: {
type: 'string'
}
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
return WIKI.models.users.getUserGroups(userId)
}
)
/**
* GET OWN EDITOR SETTINGS
*
* Per-user and per-editor, e.g. whether the markdown editor opens with its preview pane showing.
* Session-scoped like the rest of `/profile`, so it needs no permission of its own: a user can
* only ever read its own.
*/
app.get<{ Params: { editor: string } }>(
'/profile/editor-settings/:editor',
{
schema: {
summary: "Get the logged in user's settings for one editor",
tags: ['Users'],
params: {
type: 'object',
properties: {
editor: { type: 'string', description: 'Editor key, e.g. `markdown`' }
},
required: ['editor']
},
response: {
200: {
description: 'Editor settings. An object whose shape belongs to the editor.',
type: 'object',
additionalProperties: true
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
return WIKI.models.users.getEditorSettings(userId, req.params.editor)
}
)
/**
* UPDATE OWN EDITOR SETTINGS
*/
app.put<{ Params: { editor: string }; Body: Record<string, any> }>(
'/profile/editor-settings/:editor',
{
schema: {
summary: "Update the logged in user's settings for one editor",
description:
"Replaces the settings for this editor. Other editors' settings, and every other preference, are left alone.",
tags: ['Users'],
params: {
type: 'object',
properties: {
editor: { type: 'string', description: 'Editor key, e.g. `markdown`' }
},
required: ['editor']
},
body: {
type: 'object',
additionalProperties: true
},
response: {
200: {
description: 'Editor settings updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
config: { type: 'object', additionalProperties: true }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
const config = await WIKI.models.users.setEditorSettings(userId, req.params.editor, req.body)
if (config === null) {
// -> The session outlived the user it points at
return reply.unauthorized()
}
return { ok: true, config }
}
)
/**
* GET OWN AUTHENTICATION METHODS
*
* What the profile's authentication page is built from: the providers linked to the account and the
* passkeys registered against it. Session-scoped like the rest of `/profile` — a user can only ever
* see its own, and no permission expresses that.
*/
app.get(
'/profile/auth',
{
schema: {
summary: "Get the logged in user's authentication methods",
description:
'The providers the account can be signed in with, plus its registered passkeys. Secrets are never included: each provider reports only whether a password is set, whether 2FA is active, and whether the user is allowed to turn it off.',
tags: ['Users'],
response: {
200: {
description: 'Authentication methods',
type: 'object',
properties: {
authMethods: {
type: 'array',
items: {
type: 'object',
properties: {
authId: { type: 'string', format: 'uuid' },
authName: { type: 'string' },
strategyKey: { type: 'string' },
strategyIcon: { type: 'string' },
config: {
type: 'object',
properties: {
isPasswordSet: { type: 'boolean' },
isTfaSetup: { type: 'boolean' },
isTfaRequired: {
type: 'boolean',
description:
'Either this user is flagged for 2FA or the strategy enforces it. Turning 2FA off is refused while this holds.'
},
isPasswordLoginEnabled: {
type: 'boolean',
description:
'False once password login has been turned off, by the user or by an administrator.'
},
canDisablePasswordLogin: {
type: 'boolean',
description:
'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.'
}
}
}
}
}
},
passkeys: {
type: 'array',
items: { $ref: 'Passkey#' }
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
return {
authMethods: await WIKI.models.users.getProfileAuthMethods(userId),
passkeys: await WIKI.models.passkeys.list(userId)
}
}
)
/**
* CHANGE OWN PASSWORD
*/
app.put<{ Body: { strategyId: string; currentPassword: string; newPassword: string } }>(
'/profile/password',
{
schema: {
summary: "Change the logged in user's own password",
description:
'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here. Also clears any pending forced password change.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'currentPassword', 'newPassword'],
properties: {
strategyId: {
type: 'string',
format: 'uuid',
description: 'The provider whose password is being changed.'
},
currentPassword: { type: 'string', minLength: 1, maxLength: 255 },
newPassword: { type: 'string', minLength: 8, maxLength: 255 }
}
},
response: {
200: {
description: 'Password changed successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.changeOwnPassword({
userId,
strategyId: req.body.strategyId,
currentPassword: req.body.currentPassword,
newPassword: req.body.newPassword
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: 'Password changed successfully.'
}
}
)
/**
* TURN OWN PASSWORD LOGIN ON OR OFF
*/
app.put<{ Body: { strategyId: string; isEnabled: boolean } }>(
'/profile/password-login',
{
schema: {
summary: "Turn password login on or off for the logged in user's own account",
description:
'The same restriction an administrator can apply from the admin area. Turning it off is refused unless the account has another way in — a registered passkey or another linked provider — so that a user cannot lock themselves out. The password itself is kept, so turning it back on restores it.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'isEnabled'],
properties: {
strategyId: {
type: 'string',
format: 'uuid',
description:
'The provider to change, which has to be one that stores a password here.'
},
isEnabled: { type: 'boolean' }
}
},
response: {
200: {
description: 'Password login setting updated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.setPasswordLoginEnabled({
userId,
strategyId: req.body.strategyId,
isEnabled: req.body.isEnabled
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: req.body.isEnabled ? 'Password login enabled.' : 'Password login disabled.'
}
}
)
/**
* START OWN 2FA SETUP
*
* Two steps, because the server cannot know the secret reached the user's authenticator until the
* user proves it did: this hands out a QR code and a continuation token, and `PUT` activates the
* secret once a code generated from it comes back.
*/
app.post<{ Body: { strategyId: string } }>(
'/profile/tfa',
{
schema: {
summary: "Start setting up 2FA on the logged in user's account",
description:
'Generates a secret and returns the QR code to scan. The secret does nothing until a code produced by it is submitted to `PUT /users/profile/tfa` with the continuation token returned here. Starting again replaces a secret that was never activated.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId'],
properties: {
strategyId: { type: 'string', format: 'uuid' }
}
},
response: {
200: {
description: '2FA setup started',
type: 'object',
properties: {
ok: { type: 'boolean' },
continuationToken: { type: 'string' },
tfaQRImage: {
type: 'string',
description: 'The `otpauth://` URI as an SVG QR code, to be rendered as-is.'
},
tfaSecret: {
type: 'string',
description:
'The base32 secret the QR code encodes, for a user who would rather type it into an authenticator app than scan it. Only ever returned here, to the user setting 2FA up on their own account.'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
// -> The site names the entry in the user's authenticator app, and is the one being browsed
// rather than one the client names: nothing else about this request is client-chosen either
const site = req.hostname
? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
: null
try {
const { continuationToken, tfaQRImage, tfaSecret } =
await WIKI.models.users.startProfileTfaSetup({
userId,
strategyId: req.body.strategyId,
siteId: site?.id
})
return {
ok: true,
continuationToken,
tfaQRImage,
tfaSecret
}
} catch (err: any) {
rethrowAsBadRequest(err)
}
}
)
/**
* FINISH OWN 2FA SETUP
*/
app.put<{ Body: { strategyId: string; continuationToken: string; securityCode: string } }>(
'/profile/tfa',
{
schema: {
summary: 'Activate the 2FA secret the logged in user just set up',
description:
'Checks a code from the users authenticator against the secret generated by `POST /users/profile/tfa`, and activates it. A wrong code can be retried a handful of times before the continuation token is discarded and the setup has to be started again.',
tags: ['Users'],
body: {
type: 'object',
required: ['strategyId', 'continuationToken', 'securityCode'],
properties: {
strategyId: { type: 'string', format: 'uuid' },
continuationToken: { type: 'string', minLength: 1, maxLength: 255 },
securityCode: {
type: 'string',
pattern: '^[0-9]{6}$',
description: 'The six digits shown by the authenticator app.'
}
}
},
response: {
200: {
description: '2FA activated successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.confirmTfaSetup({
userId,
strategyId: req.body.strategyId,
continuationToken: req.body.continuationToken,
securityCode: req.body.securityCode
})
} catch (err: any) {
rethrowAsBadRequest(err)
}
return {
ok: true,
message: '2FA enabled successfully.'
}
}
)
/**
* TURN OWN 2FA OFF
*/
app.delete<{ Params: { strategyId: string } }>(
'/profile/tfa/:strategyId',
{
schema: {
summary: "Turn 2FA off on the logged in user's account",
description:
'Forgets the secret, so setting 2FA up again starts from a new one. Refused when the account is flagged for 2FA or the strategy enforces it — the next login would only ask for it again.',
tags: ['Users'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
response: {
204: {
description: '2FA turned off successfully'
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
await WIKI.models.users.disableTfa(userId, req.params.strategyId)
} catch (err: any) {
rethrowAsBadRequest(err)
}
return reply.code(204).send()
}
)
/**
* START REGISTERING A PASSKEY
*/
app.post(
'/profile/passkeys/challenge',
{
schema: {
summary: 'Get the options for registering a new passkey',
description:
"Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `POST /users/profile/passkeys`. The credential is bound to the hostname of this request, so a passkey registered on one site of a multi-site instance does not work on another.",
tags: ['Users'],
response: {
200: {
description: 'Registration options',
type: 'object',
properties: {
ok: { type: 'boolean' },
registrationOptions: {
type: 'object',
additionalProperties: true,
description: 'A WebAuthn `PublicKeyCredentialCreationOptions`, JSON-encoded.'
}
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({
userId,
hostname: req.hostname,
origin: req.headers.origin
})
// -> Kept out of the client's hands: what the authenticator signs is only worth anything if the
// challenge it answers is one this server remembers issuing
req.session.passkeyRegistration = pending
return {
ok: true,
registrationOptions
}
} catch (err: any) {
rethrowAsBadRequest(err)
}
}
)
/**
* FINISH REGISTERING A PASSKEY
*/
app.post<{ Body: { name: string; registrationResponse: Record<string, any> } }>(
'/profile/passkeys',
{
schema: {
summary: 'Register the passkey an authenticator just created',
tags: ['Users'],
body: {
type: 'object',
required: ['name', 'registrationResponse'],
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255,
description: 'What to call it in the list, e.g. the device it lives on.'
},
registrationResponse: {
type: 'object',
additionalProperties: true,
description: "The browser's WebAuthn registration response, JSON-encoded."
}
}
},
response: {
200: {
description: 'Passkey registered successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
passkey: { $ref: 'Passkey#' }
}
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
try {
const passkey = await WIKI.models.passkeys.finalizeRegistration({
userId,
name: req.body.name,
registrationResponse: req.body.registrationResponse as any,
pending: req.session.passkeyRegistration
})
return {
ok: true,
passkey
}
} catch (err: any) {
rethrowAsBadRequest(err)
} finally {
// -> Spent either way: a rejected response does not get a second go at the same challenge
req.session.passkeyRegistration = undefined
}
}
)
/**
* REMOVE A PASSKEY
*/
app.delete<{ Params: { passkeyId: string } }>(
'/profile/passkeys/:passkeyId',
{
schema: {
summary: 'Remove one of the logged in users passkeys',
description:
'Only this instance forgets it — the credential itself lives on the users device and has to be deleted there too.',
tags: ['Users'],
params: {
type: 'object',
properties: {
passkeyId: {
type: 'string',
description: 'The credential ID, as listed by `GET /users/profile/auth`.'
}
},
required: ['passkeyId']
},
response: {
204: {
description: 'Passkey removed successfully'
}
}
}
},
async (req, reply) => {
const userId = sessionUserId(req)
if (!userId) {
return reply.unauthorized()
}
if (!(await WIKI.models.passkeys.remove(userId, req.params.passkeyId))) {
return reply.notFound('You have no passkey with this ID.')
}
return reply.code(204).send()
}
)
/**
* GET USER DEFAULTS
*
* Instance-wide, not per-site: stored as the `userDefaults` key of the settings table.
*/
app.get(
'/defaults',
{
config: {
permissions: ['read:users', 'manage:users']
},
schema: {
summary: 'Get the defaults applied to new users',
tags: ['Users'],
response: {
200: {
description: 'User defaults',
type: 'object',
$ref: 'UserDefaults#'
}
}
}
},
async () => {
return WIKI.config.userDefaults
}
)
/**
* UPDATE USER DEFAULTS
*/
app.put<{ Body: { timezone?: string; dateFormat?: string; timeFormat?: string } }>(
'/defaults',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: 'Update the defaults applied to new users',
description:
'These are instance-wide, not per-site. Existing users keep their own preferences.',
tags: ['Users'],
body: {
$ref: 'UserDefaults#'
},
response: {
200: {
description: 'User defaults updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
// -> A bad time zone would break every date the affected users see, and the list of valid
// zones is only known at runtime, so it cannot be expressed as a schema enum
if (req.body.timezone !== undefined) {
if (!Intl.supportedValuesOf('timeZone').includes(req.body.timezone)) {
throw new CustomError(
'userDefaultsInvalidTimezone',
`Not a recognized IANA time zone: ${req.body.timezone}`
)
}
}
const patch: Record<string, any> = {}
for (const key of ['timezone', 'dateFormat', 'timeFormat'] as const) {
if (req.body[key] !== undefined) {
patch[key] = req.body[key]
}
}
if (Object.keys(patch).length < 1) {
throw new CustomError('userDefaultsEmpty', 'No user defaults provided to update.')
}
const previousDefaults = WIKI.config.userDefaults
WIKI.config.userDefaults = { ...previousDefaults, ...patch }
if (!(await WIKI.configSvc.saveToDb(['userDefaults']))) {
WIKI.config.userDefaults = previousDefaults
return reply.internalServerError('Failed to save user defaults.')
}
return {
ok: true,
message: 'User defaults updated successfully.'
}
}
)
app.get<{ Params: { userId: string } }>(
'/:userId',
{
config: {
permissions: ['read:users', 'manage:users']
},
schema: {
summary: 'Get user info',
description:
'Returns the user with its group membership and linked authentication providers.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
response: {
200: {
description: 'User info',
type: 'object',
$ref: 'User#'
}
}
}
},
async (req, reply) => {
const user = await WIKI.models.users.getUserDetail(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
return user
}
)
/**
* CREATE USER
*/
app.post<{
Body: {
name: string
email: string
password: string
groups?: string[]
mustChangePassword?: boolean
sendWelcomeEmail?: boolean
sendWelcomeEmailFromSiteId?: string
}
}>(
'/',
{
config: {
permissions: ['create:users', 'manage:users']
},
schema: {
summary: 'Create a new user',
description:
'Creates a user authenticated against the local strategy. `sendWelcomeEmail` is accepted but not yet supported, as the server has no mail transport.',
tags: ['Users'],
body: {
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
password: {
type: 'string',
minLength: 8,
maxLength: 255
},
groups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
},
mustChangePassword: {
type: 'boolean',
default: false
},
sendWelcomeEmail: {
type: 'boolean',
default: false
},
sendWelcomeEmailFromSiteId: {
type: 'string',
format: 'uuid'
}
},
examples: [
{
name: 'Jane Doe',
email: 'jane@example.com',
password: 'a-long-password',
groups: []
}
]
},
response: {
200: {
description: 'User created successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid'
}
}
}
}
}
},
async (req, reply) => {
if (!/^[^<>"]+$/.test(req.body.name)) {
throw new CustomError('userCreateInvalidName', 'Invalid User Name')
}
if (await WIKI.models.users.getByEmail(req.body.email.toLowerCase())) {
throw new CustomError('userCreateDuplicateEmail', 'A user with this email already exists.')
}
// -> There is no mail transport yet, so accepting this flag would silently drop the request
if (req.body.sendWelcomeEmail) {
throw new CustomError(
'userCreateWelcomeEmailUnavailable',
'Sending a welcome email is not supported yet, as mail delivery is not implemented.'
)
}
try {
const id = await WIKI.models.users.createUser({
name: req.body.name,
email: req.body.email,
password: req.body.password,
groups: req.body.groups ?? [],
mustChangePassword: req.body.mustChangePassword ?? false
})
return {
ok: true,
message: 'User created successfully.',
id
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* UPDATE USER
*/
app.put<{ Params: { userId: string }; Body: UserUpdateBody }>(
'/:userId',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: 'Update a user',
description:
'Updates any subset of the user fields. Omitted fields are left unchanged. Passing `groups` replaces the group membership entirely — except for system users (the guest account), whose membership is fixed.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
body: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 255
},
email: {
type: 'string',
format: 'email',
maxLength: 255
},
isActive: {
type: 'boolean'
},
isVerified: {
type: 'boolean'
},
meta: {
type: 'object',
additionalProperties: true
},
prefs: {
type: 'object',
additionalProperties: true
},
groups: {
type: 'array',
items: {
type: 'string',
format: 'uuid'
}
},
auth: {
type: 'object',
description:
'Local-strategy flags: `mustChangePwd`, `restrictLogin`, `tfaRequired`. Secrets cannot be set here — use the password endpoint.',
properties: {
mustChangePwd: {
type: 'boolean'
},
restrictLogin: {
type: 'boolean'
},
tfaRequired: {
type: 'boolean'
}
}
}
}
},
response: {
200: {
description: 'User updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const user = await WIKI.models.users.getById(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
const systemUserRefusal = await systemUserGuard(req, user.id)
if (systemUserRefusal) {
throw systemUserRefusal
}
// -> Collect only the fields actually provided
const patch: UserPatch = {}
for (const key of ['name', 'email', 'isActive', 'isVerified', 'meta', 'prefs'] as const) {
if (req.body[key] !== undefined) {
;(patch as Record<string, any>)[key] = req.body[key]
}
}
if (
Object.keys(patch).length < 1 &&
req.body.groups === undefined &&
req.body.auth === undefined
) {
throw new CustomError('userUpdateEmpty', 'No user fields provided to update.')
}
// -> Email is unique, so a clash needs a clearer answer than a constraint violation
if (patch.email && patch.email.toLowerCase() !== user.email.toLowerCase()) {
if (await WIKI.models.users.getByEmail(patch.email.toLowerCase())) {
throw new CustomError(
'userUpdateDuplicateEmail',
'A user with this email already exists.'
)
}
}
// -> Group membership is replaced wholesale here, which would otherwise be a way around the
// guards on the groups endpoint.
if (req.body.groups !== undefined) {
// -> The guest account must stay in the guests group and nowhere else. Resending the
// membership unchanged is allowed, so that saving another field is not blocked.
if (user.isSystem) {
const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
const requested = req.body.groups
const unchanged =
current.length === requested.length && current.every((id) => requested.includes(id))
if (!unchanged) {
return reply.conflict('Cannot change the group membership of a system user.')
}
}
/*
Handing somebody `manage:system` by putting them in a group that carries it. Only ADDING is
checked: a user already in such a group is protected by `systemUserGuard` above, which has
refused this request before it gets here.
*/
if (!WIKI.models.groups.holdsSystemPermission(req)) {
const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
const systemGroupIds = await WIKI.models.groups.systemGroupIds()
const added = req.body.groups.filter((id) => !current.includes(id))
if (added.some((id) => systemGroupIds.includes(id))) {
throw new CustomError(
'groupMembershipSystemProtected',
'Only a user who holds the manage:system permission can add a user to a group that has it.',
403
)
}
}
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
const wasRootAdmin = await WIKI.models.groups.isUserInGroup(
rootAdminGroupId,
req.params.userId
)
if (wasRootAdmin && !req.body.groups.includes(rootAdminGroupId)) {
if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
return reply.conflict('Cannot remove the last user from the root administrators group.')
}
}
}
try {
if (Object.keys(patch).length > 0) {
await WIKI.models.users.updateUser(req.params.userId, patch)
}
if (req.body.groups !== undefined) {
await WIKI.models.users.setUserGroups(req.params.userId, req.body.groups)
}
if (req.body.auth !== undefined) {
await WIKI.models.users.setUserAuthFlags(req.params.userId, req.body.auth)
}
return {
ok: true,
message: 'User updated successfully.'
}
} catch (err: any) {
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
/**
* SET USER PASSWORD
*/
app.put<{
Params: { userId: string }
Body: { newPassword: string; mustChangePassword?: boolean }
}>(
'/:userId/password',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: "Set a user's password",
description: 'Replaces the local-strategy password. Other linked providers are untouched.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
body: {
type: 'object',
required: ['newPassword'],
properties: {
newPassword: {
type: 'string',
minLength: 8,
maxLength: 255
},
mustChangePassword: {
type: 'boolean',
default: false
}
}
},
response: {
200: {
description: 'Password updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const systemUserRefusal = await systemUserGuard(req, req.params.userId)
if (systemUserRefusal) {
throw systemUserRefusal
}
const updated = await WIKI.models.users.setUserPassword({
id: req.params.userId,
newPassword: req.body.newPassword,
mustChangePassword: req.body.mustChangePassword ?? false
})
if (!updated) {
return reply.notFound('User does not exist.')
}
return {
ok: true,
message: 'User password updated successfully.'
}
}
)
app.delete<{ Params: { userId: string } }>(
'/:userId',
{
config: {
permissions: ['manage:users']
},
schema: {
summary: 'Delete a user',
description:
'System users cannot be deleted, nor the account the caller is signed in as, nor the last user of the root administrators group. A user who has authored pages or assets cannot be deleted either — deactivate them, or reassign what they own.',
tags: ['Users'],
params: {
type: 'object',
properties: {
userId: {
type: 'string',
format: 'uuid'
}
},
required: ['userId']
},
response: {
204: {
description: 'User deleted successfully'
}
}
}
},
async (req, reply) => {
const user = await WIKI.models.users.getById(req.params.userId)
if (!user) {
return reply.notFound('User does not exist.')
}
const systemUserRefusal = await systemUserGuard(req, user.id)
if (systemUserRefusal) {
throw systemUserRefusal
}
// -> The guest account is the only system user, and anonymous access is resolved through it
if (user.isSystem) {
return reply.conflict('Cannot delete a system user.')
}
/*
Not your own account, whatever permissions you hold: the request would end the session making
it, and an administrator who did it by accident has nothing left to undo it with. Another
administrator can — which is also the answer to an account that has to go and cannot ask.
*/
if (user.id === sessionUserId(req)) {
return reply.conflict('You cannot delete your own account. Another administrator can.')
}
// -> Emptying the root administrators group would lock everyone out of system management
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) {
if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
return reply.conflict('Cannot delete the last user of the root administrators group.')
}
}
try {
await WIKI.models.users.deleteUser(user.id)
return reply.code(204).send()
} catch (err: any) {
// -> Pages and assets reference users without a cascade, so a user who authored content
// cannot be removed. That is a conflict to report, not a server fault.
if (err.cause?.code === '23503' || err.code === '23503') {
return reply.conflict(
'Cannot delete a user who still owns pages or assets. Reassign them first.'
)
}
WIKI.logger.warn(err)
return reply.internalServerError()
}
}
)
}
export default routes