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 prefs?: Record groups?: string[] auth?: Record } /** 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 { 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 { 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 { 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 }>( '/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 user’s 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 } }>( '/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 user’s passkeys', description: 'Only this instance forgets it — the credential itself lives on the user’s 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 = {} 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)[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