mirror of https://github.com/requarks/wiki
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.
1088 lines
31 KiB
1088 lines
31 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 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 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
|