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

1001 lines
28 KiB

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<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
}
/**
* 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 }
}
)
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<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.')
}
// -> 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.')
}
}
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