import { CustomError } 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 } /** * 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 } } ) app.get( '/whoami', { schema: { summary: 'Get currently logged in user info', tags: ['Users'] } }, async (req, reply) => { reply.preventCache() if (req.session?.authenticated) { return { authenticated: true, ...req.session.user, permissions: ['manage:system'] // TODO: pull actual permissions } } else { return { authenticated: false } } } ) /** * 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 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.') } // -> 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.') } } 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 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 can the last user of the root administrators group.', 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.') } if (user.isSystem) { return reply.conflict('Cannot delete a system user.') } // -> 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