From 40a0abc3bb7ab5f86e49aedf17f606d521e5f691 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 26 Jul 2026 19:07:02 +0000 Subject: [PATCH] refactor: wire profile views --- backend/api/authentication.ts | 76 +++ backend/api/hooks.ts | 2 +- backend/api/schemas/user.ts | 106 +++ backend/api/system.ts | 32 +- backend/api/users.ts | 329 ++++++++- backend/controllers/user.ts | 55 ++ backend/helpers/images.ts | 78 +++ backend/index.ts | 1 + backend/locales/en.json | 5 +- backend/models/extensions.ts | 115 +++- backend/models/hooks.ts | 6 +- backend/models/users.ts | 226 ++++++- .../modules/extensions/sharp/definition.yml | 10 +- backend/package-lock.json | 639 +++++++++++++++++- backend/package.json | 3 + frontend/src/App.vue | 11 +- frontend/src/components/AccountMenu.vue | 2 +- frontend/src/pages/AdminExtensions.vue | 9 +- frontend/src/pages/ProfileAvatar.vue | 110 ++- frontend/src/pages/ProfileGroups.vue | 36 +- frontend/src/pages/ProfileInfo.vue | 130 ++-- frontend/src/stores/user.js | 91 ++- 22 files changed, 1884 insertions(+), 188 deletions(-) create mode 100644 backend/controllers/user.ts create mode 100644 backend/helpers/images.ts diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index c3fecb982..a806e7b80 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -280,6 +280,82 @@ async function routes(app: FastifyInstance) { } ) + /** + * LOGOUT + */ + app.post<{ Params: { siteId: string } }>( + '/sites/:siteId/auth/logout', + { + schema: { + summary: 'Logout', + description: + "Destroys the current session and answers with where to send the user next: the first of the user's groups that sets a logout redirect, otherwise the site's own setting, otherwise the site root. A request that was not logged in gets the same answer rather than an error, so that a client acting on a session the server has already forgotten still ends up somewhere sensible.", + tags: ['Authentication'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId'] + }, + response: { + 200: { + description: 'Logged out successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + redirect: { + type: 'string', + description: 'A path within this wiki, or an absolute URL if one is configured.' + } + } + } + } + } + }, + async (req, reply) => { + const user = req.session?.authenticated ? req.session.user : null + + // -> Resolved before the session goes away, since it depends on who was logged in + const redirect = await WIKI.models.users.getLogoutRedirect( + user?.id ?? null, + req.params.siteId + ) + + if (req.session) { + // -> Drops the stored session, so the cookie the browser still holds refers to nothing + await req.session.destroy() + } + // -> And clear that cookie too: `destroy()` detaches the session, which leaves the plugin's own + // save hook with nothing to do. Name and options match the registration in `index.ts`. + reply.clearCookie('wikiSession') + + if (user) { + WIKI.models.flags.authDebug( + `User ${user.id} <${user.email}> logged out, redirecting to ${redirect}` + ) + await WIKI.models.hooks.emit('user:logout', { + userId: user.id, + ip: req.ip, + metadata: { + name: user.name, + email: user.email + } + }) + } + + return { + ok: true, + redirect + } + } + ) + /** * LIST AUTHENTICATION MODULES */ diff --git a/backend/api/hooks.ts b/backend/api/hooks.ts index 7e911c803..3d3158148 100644 --- a/backend/api/hooks.ts +++ b/backend/api/hooks.ts @@ -80,7 +80,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'List the events a webhook can subscribe to', description: - 'Only `user:join` and `user:login` are emitted at the moment. Pages, assets, comments and logout are not implemented yet, so a subscription to those is stored but never triggered.', + 'Only the `user:*` events are emitted at the moment. Pages, assets and comments are not implemented yet, so a subscription to those is stored but never triggered.', tags: ['Webhooks'], response: { 200: { diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index d7af73b84..163ff830c 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -80,6 +80,112 @@ export async function registerSchemas(app: FastifyInstance): Promise { } }) + /** + * USER PROFILE - The logged in user's own view of itself + * + * The `meta` / `prefs` blobs are flattened into plain fields here. Values are deliberately typed as + * strings rather than enums: this is the serialized response, and a preference stored before an + * option existed must still be readable. + */ + app.addSchema({ + $id: 'UserProfile', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + email: { + type: 'string', + format: 'email' + }, + hasAvatar: { + type: 'boolean' + }, + location: { + type: 'string' + }, + jobTitle: { + type: 'string' + }, + pronouns: { + type: 'string' + }, + timezone: { + type: 'string', + description: 'IANA time zone name, or an empty string to use the client time zone.' + }, + dateFormat: { + type: 'string', + description: 'Empty string means the locale default.' + }, + timeFormat: { + type: 'string' + }, + appearance: { + type: 'string' + }, + cvd: { + type: 'string', + description: 'Color vision deficiency to adjust the palette for.' + } + } + }) + + /** + * USER PROFILE UPDATE - The fields a user may change on its own profile + * + * The email is absent on purpose: it identifies the account and is the local strategy's username. + */ + app.addSchema({ + $id: 'UserProfileUpdate', + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + maxLength: 255 + }, + location: { + type: 'string', + maxLength: 255 + }, + jobTitle: { + type: 'string', + maxLength: 255 + }, + pronouns: { + type: 'string', + maxLength: 255 + }, + timezone: { + type: 'string', + description: 'IANA time zone name, e.g. `America/New_York`.', + maxLength: 255 + }, + dateFormat: { + type: 'string', + description: 'Empty string means the locale default.', + enum: ['', 'DD/MM/YYYY', 'DD.MM.YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD'] + }, + timeFormat: { + type: 'string', + enum: ['12h', '24h'] + }, + appearance: { + type: 'string', + enum: ['site', 'light', 'dark'] + }, + cvd: { + type: 'string', + enum: ['none', 'protanopia', 'deuteranopia', 'tritanopia'] + } + } + }) + /** * USER - All fields */ diff --git a/backend/api/system.ts b/backend/api/system.ts index ad4d0e449..95b829ac0 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -504,9 +504,9 @@ async function routes(app: FastifyInstance) { permissions: ['manage:system'] }, schema: { - summary: 'Install an extension', + summary: 'Install or reinstall an extension', description: - 'Only extensions flagged `isInstallable` can be installed from here. None currently are: Git and Pandoc come from the operating system, Sharp and Puppeteer are optional dependencies, so both are installed outside the application and this answers 409 pointing at the documentation.', + 'Only extensions flagged `isInstallable` can be installed from here — currently Sharp, which is an npm package. It already ships as an optional dependency, so this is mostly a repair: it refetches the package and the prebuilt binary for this OS and architecture, which is what to reach for when the native binary is missing or does not match the platform. Git and Pandoc come from the operating system and answer 409 pointing at the documentation. Runs npm and can take minutes.', tags: ['System'], params: { type: 'object', @@ -528,6 +528,11 @@ async function routes(app: FastifyInstance) { }, message: { type: 'string' + }, + restartRequired: { + type: 'boolean', + description: + 'True when this server already tried and failed to load the module. Node replays a failed module load for the life of the process, so the repaired files cannot be used until the server restarts.' } } } @@ -548,9 +553,26 @@ async function routes(app: FastifyInstance) { ) } - // -> No extension declares itself installable yet; an installer belongs with the extension - // that needs it, next to its definition - return reply.notImplemented('Installing this extension is not implemented yet.') + try { + await WIKI.models.extensions.install(definition) + } catch (err: any) { + // -> The message carries npm's own output, which is the only thing that explains a failure + // like a missing build toolchain. An administrator is the only caller. + return reply.internalServerError(err.message) + } + + // -> A fresh install is usable at once, since nothing has tried to load it yet. Repairing one this + // process already choked on is a different story, and saying so beats leaving an administrator + // to wonder why nothing changed. + const restartRequired = WIKI.models.extensions.hasLoadFailed(definition) + + return { + ok: true, + message: restartRequired + ? `${definition.title} was reinstalled, but this server has to be restarted before it can use it.` + : `${definition.title} installed successfully.`, + restartRequired + } } ) diff --git a/backend/api/users.ts b/backend/api/users.ts index 4472f915c..65e98ae17 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -1,6 +1,7 @@ import { CustomError } from '../helpers/common.ts' -import type { FastifyInstance } from 'fastify' -import type { UserPatch } from '../models/users.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 @@ -13,10 +14,48 @@ interface UserUpdateBody { 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 } }>( @@ -100,6 +139,292 @@ async function routes(app: FastifyInstance) { } ) + /** + * 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 * diff --git a/backend/controllers/user.ts b/backend/controllers/user.ts new file mode 100644 index 000000000..2640a0f2c --- /dev/null +++ b/backend/controllers/user.ts @@ -0,0 +1,55 @@ +import { validate as uuidValidate } from 'uuid' +import crypto from 'node:crypto' +import type { FastifyInstance } from 'fastify' + +/** + * An avatar changes whenever its owner uploads a new one, and the URL never carries a version — so it + * is always revalidated, and the ETag turns that into an empty 304 rather than a re-download. + */ +const AVATAR_CACHE = 'private, no-cache' + +/** + * _user Routes + * + * Public, like `_site` and `_icons`: avatars appear next to page authors and in user pickers, so a + * reader who can see a page can see them. Only what a user chose to upload is served, under a URL that + * has to be known — nothing here enumerates users. + */ +async function routes(app: FastifyInstance) { + /** + * USER AVATAR + * + * `current` resolves to the logged in user, as it does for a site's own assets — a page showing its + * own avatar then needs no user ID to build the URL with. + */ + app.get<{ Params: { userId: string } }>('/:userId/avatar', async (req, reply) => { + let userId: string | null = null + if (req.params.userId === 'current') { + userId = req.session?.authenticated ? (req.session.user?.id ?? null) : null + } else if (uuidValidate(req.params.userId)) { + userId = req.params.userId + } + if (!userId) { + return reply.notFound('User not found') + } + + const avatar = await WIKI.models.users.getAvatar(userId) + if (!avatar) { + return reply.notFound('This user has no avatar') + } + + const etag = `"${crypto.createHash('sha1').update(avatar.data).digest('hex')}"` + reply.header('ETag', etag) + reply.header('Cache-Control', AVATAR_CACHE) + // -> The bytes came from a user, so the browser must take the type at its word rather than looking + // for something more interesting in them + reply.header('X-Content-Type-Options', 'nosniff') + if (req.headers['if-none-match'] === etag) { + return reply.code(304).send() + } + + return reply.type(avatar.mime).send(avatar.data) + }) +} + +export default routes diff --git a/backend/helpers/images.ts b/backend/helpers/images.ts new file mode 100644 index 000000000..01f84c050 --- /dev/null +++ b/backend/helpers/images.ts @@ -0,0 +1,78 @@ +/** + * Image helpers + * + * Only what the server needs to accept an uploaded image safely: recognizing what it actually is, and + * normalizing it when the Sharp extension is available. + */ + +/** The image formats an upload may use. */ +export const imageMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const + +export type ImageMimeType = (typeof imageMimeTypes)[number] + +/** + * Recognize an image from its leading bytes. + * + * The declared `Content-Type` of an upload is whatever the client felt like sending, so the stored + * bytes are what decides — both for rejecting a file that is not an image at all and for serving it + * back with a truthful type later. + * + * @returns The MIME type, or null if these bytes are not one of the supported formats + */ +export function detectImageMime(data: Buffer): ImageMimeType | null { + if (data.length < 12) { + return null + } + if (data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png' + } + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return 'image/jpeg' + } + if (/^GIF8[79]a$/.test(data.subarray(0, 6).toString('latin1'))) { + return 'image/gif' + } + // -> A WebP is a RIFF container whose form type, at byte 8, is `WEBP` + if ( + data.subarray(0, 4).toString('latin1') === 'RIFF' && + data.subarray(8, 12).toString('latin1') === 'WEBP' + ) { + return 'image/webp' + } + return null +} + +/** + * Resize an image to a square JPEG, using the Sharp extension. + * + * Sharp ships as an optional dependency, so it is normally there — but an optional dependency is + * exactly one that may be missing, whether because the platform has no prebuilt binary or because the + * install skipped it. So this reports back rather than failing, leaving the caller to decide whether + * the original bytes will do; the admin area's extensions view is where it gets (re)installed. + * + * @returns The resized JPEG, or null if Sharp is not usable on this system + */ +export async function resizeImageToSquareJpeg(data: Buffer, size: number): Promise { + const definition = WIKI.models.extensions.getDefinition('sharp') + if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) { + return null + } + // -> The specifier is held in a variable on purpose: Sharp is an *optional* dependency, so a literal + // `import('sharp')` would be a type error wherever the optional install was skipped. + const specifier = 'sharp' + try { + const { default: sharp } = await import(specifier) + return await sharp(data) + .resize(size, size, { fit: 'cover', position: 'centre' }) + .jpeg({ quality: 90 }) + .toBuffer() + } catch (err: any) { + // -> Present but unusable, which is what a native binary built for another platform looks like. The + // caller falls back to the original bytes rather than refusing the upload; the failure is + // recorded because Node will keep replaying it until the server restarts, so reinstalling Sharp + // from the admin area cannot help this process. + WIKI.models.extensions.noteLoadFailure(specifier) + WIKI.logger.warn(`Could not resize an image with Sharp: ${err.message}`) + return null + } +} diff --git a/backend/index.ts b/backend/index.ts index 9f603cd40..cc3fbe352 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -550,6 +550,7 @@ async function initHTTPServer() { app.register(import('./api/index.ts'), { prefix: '/_api' }) app.register(import('./controllers/site.ts'), { prefix: '/_site' }) app.register(import('./controllers/icons.ts'), { prefix: '/_icons' }) + app.register(import('./controllers/user.ts'), { prefix: '/_user' }) // ---------------------------------------- // Error handling diff --git a/backend/locales/en.json b/backend/locales/en.json index 4d5b460e7..200082da8 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -218,6 +218,7 @@ "admin.extensions.incompatible": "not compatible", "admin.extensions.install": "Install", "admin.extensions.installFailed": "Failed to install extension.", + "admin.extensions.installRestartRequired": "Extension reinstalled successfully, but the server must be restarted before it can be used.", "admin.extensions.installSuccess": "Extension installed successfully.", "admin.extensions.installed": "Installed", "admin.extensions.installing": "Installing extension...", @@ -1417,7 +1418,7 @@ "common.comments.updateComment": "Update Comment", "common.comments.updateSuccess": "Comment was updated successfully.", "common.comments.viewDiscussion": "View Discussion", - "common.datetime": "{date} 'at' {time}", + "common.datetime": "{date} at {time}", "common.duration.days": "Day(s)", "common.duration.every": "Every", "common.duration.hours": "Hour(s)", @@ -1929,6 +1930,7 @@ "profile.avatarUploadDisabled": "Your avatar is set by your organization and cannot be changed.", "profile.avatarUploadFailed": "Failed to upload user profile picture.", "profile.avatarUploadHint": "For best results, use a 180x180 image of type JPG or PNG.", + "profile.avatarUploadInvalidType": "Must be a PNG, JPEG, WebP or GIF image.", "profile.avatarUploadSuccess": "Profile picture uploaded successfully.", "profile.avatarUploadTitle": "Upload your user profile picture.", "profile.cvd": "Color Vision Deficiency", @@ -1951,6 +1953,7 @@ "profile.groupsInfo": "You're currently part of the following groups:", "profile.groupsLoadingFailed": "Failed to load groups.", "profile.groupsNone": "You're not part of any group.", + "profile.infoLoadingFailed": "Failed to load your profile.", "profile.jobTitle": "Job Title", "profile.jobTitleHint": "Your position in your organization; shown on your profile page.", "profile.localeDefault": "Locale Default", diff --git a/backend/models/extensions.ts b/backend/models/extensions.ts index 88b029517..4584f9444 100644 --- a/backend/models/extensions.ts +++ b/backend/models/extensions.ts @@ -2,6 +2,16 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import yaml from 'js-yaml' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +/** How long an install may run before it is given up on — fetching a native binary is not instant. */ +const installTimeout = 5 * 60 * 1000 + +/** How much npm output is kept when reporting a failure, taken from the end where the error is. */ +const installErrorLength = 800 /** How an extension's presence on this system is detected. */ export interface ExtensionDetection { @@ -85,14 +95,28 @@ async function moduleExists(specifier: string): Promise { * Extensions model * * Optional third-party tooling that unlocks extra functionality — a Git binary, Pandoc, Sharp, - * Puppeteer. Each lives in `modules/extensions//definition.yml`, which declares how to detect - * it and what it is compatible with. Nothing here installs anything: these are installed with the - * system package manager or as optional dependencies, which is what the admin area links out to. + * Puppeteer. Each lives in `modules/extensions//definition.yml`, which declares how to detect it, + * what it is compatible with, and whether it can be installed from here. + * + * Most cannot: a Git or Pandoc binary comes from the system package manager, and the admin area links + * out to the instructions. An extension detected as a `module` is an npm package, and `install()` can + * (re)install that with npm — which for Sharp, shipped as an optional dependency, is how a native + * binary that is missing or does not match the platform gets replaced. */ class Extensions { /** Definitions read from disk, refreshed by `refreshFromDisk()`. */ definitions: ExtensionDefinition[] = [] + /** + * npm specifiers this process tried to load and could not, reported by whoever attempted it. + * + * Node caches a failed module load for the lifetime of the process: an `import()` that threw keeps + * throwing the same error afterwards, even once the files it was missing are back on disk. So a + * repaired install does not take effect here until the server restarts, and the only way to know + * that is to remember having failed. + */ + loadFailures = new Set() + /** * Load the extension definitions from disk. */ @@ -169,6 +193,91 @@ class Extensions { return results } + /** + * Install, or reinstall, an extension with npm. + * + * Only a `module` extension can be installed from here — a `command` extension is an operating + * system package, and no amount of npm will produce one. Callers are expected to have checked + * `isInstallable` and `isCompatible` first; this repeats the detection check afterwards, since npm + * exiting zero and the module actually being there are not the same claim. + * + * Reinstalling is the point as much as installing is. Sharp is a declared optional dependency, so an + * ordinary install already has it — what goes wrong is its *native* binary: an image built on one + * platform and run on another, or an install that skipped optional dependencies, leaves the JavaScript + * package in place and the binary for this OS and architecture missing. Hence the flags: + * + * - `--no-save` because the manifest already declares the package, and an HTTP request has no + * business rewriting the manifests the release was built from. + * - `--force` so npm refetches rather than deciding an already-present but unusable copy is fine. + * - `--include=optional` because the per-platform binaries are themselves optional dependencies of + * the package, and omitting them is the usual cause of the failure being repaired here. + * + * @throws If the extension cannot be installed this way, if npm fails, or if the module is still + * missing afterwards + */ + async install(definition: ExtensionDefinition): Promise { + if (definition.detect?.type !== 'module') { + throw new Error(`${definition.title} is not an npm package, so it cannot be installed here.`) + } + const specifier = definition.detect.value + + WIKI.logger.info(`Installing extension ${definition.key} (npm package ${specifier})...`) + try { + const { stdout } = await execFileAsync( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + [ + 'install', + '--no-save', + '--force', + '--include=optional', + '--no-audit', + '--no-fund', + specifier + ], + { + cwd: WIKI.SERVERPATH, + timeout: installTimeout, + windowsHide: true, + // -> `npm.cmd` is a batch file, which Node will not run without a shell. Nothing here comes + // from a request: the package name is read from a definition on disk. + shell: process.platform === 'win32' + } + ) + WIKI.logger.debug(stdout.trim()) + } catch (err: any) { + // -> npm says what went wrong on stderr, and the tail of it is the part worth passing on + const detail: string = (err.stderr || err.stdout || err.message || '').toString().trim() + WIKI.logger.warn(`Failed to install extension ${definition.key}:`) + WIKI.logger.warn(detail || err) + throw new Error( + `npm could not install ${specifier}: ${detail.slice(-installErrorLength) || 'no output'}` + ) + } + + if (!(await this.isInstalled(definition))) { + throw new Error( + `npm reported success but ${specifier} is still not present in node_modules. Check the server logs.` + ) + } + WIKI.logger.info(`Extension ${definition.key} is installed. [ OK ]`) + } + + /** + * Record that loading a module failed in this process, so that a later reinstall can say a restart is + * needed rather than claim the extension is ready to use. + */ + noteLoadFailure(specifier: string): void { + this.loadFailures.add(specifier) + } + + /** + * Whether this process has already failed to load the extension's module, and therefore cannot use it + * however healthy the files on disk now are. + */ + hasLoadFailed(definition: ExtensionDefinition): boolean { + return definition.detect?.type === 'module' && this.loadFailures.has(definition.detect.value) + } + /** * A single definition, or null if there is no extension with this key */ diff --git a/backend/models/hooks.ts b/backend/models/hooks.ts index 98f3090cd..8e84d4f3b 100644 --- a/backend/models/hooks.ts +++ b/backend/models/hooks.ts @@ -31,10 +31,10 @@ export type HookEvent = (typeof HOOK_EVENTS)[number] /** * The events something in the server actually emits today. * - * Kept as an explicit list rather than inferred from the prefix: `user:logout` looks like it belongs - * here, but there is no logout route yet. Add an event here when you add its `emit()` call. + * Kept as an explicit list rather than inferred from the prefix, since the page, asset and comment + * events have no emit point yet. Add an event here when you add its `emit()` call. */ -export const EMITTED_EVENTS: HookEvent[] = ['user:join', 'user:login'] +export const EMITTED_EVENTS: HookEvent[] = ['user:join', 'user:login', 'user:logout'] /** A webhook as exposed by the API. */ export interface Hook { diff --git a/backend/models/users.ts b/backend/models/users.ts index 03c679a86..043ace997 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -3,6 +3,7 @@ import { authentication as authenticationTable, groups as groupsTable, sessions as sessionsTable, + userAvatars, userGroups, users as usersTable, userKeys @@ -10,6 +11,7 @@ import { import { and, count, eq, ilike, inArray, notExists, or, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { flatten, uniq } from 'es-toolkit/array' +import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts' import type { SystemIds } from './types.ts' /** The essential user fields, mirroring the `UserCore` API schema. */ @@ -55,6 +57,48 @@ export interface UserPatch { prefs?: Record } +/** + * The self-service view of a user, flattening the `meta` and `prefs` blobs into the fields the + * profile page shows. Mirrors the `UserProfile` API schema. + */ +export interface UserProfile { + id: string + name: string + email: string + hasAvatar: boolean + location: string + jobTitle: string + pronouns: string + timezone: string + dateFormat: string + timeFormat: string + appearance: string + cvd: string +} + +/** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */ +export interface UserProfilePatch { + name?: string + location?: string + jobTitle?: string + pronouns?: string + timezone?: string + dateFormat?: string + timeFormat?: string + appearance?: string + cvd?: string +} + +/** The `meta` keys the profile owns, and the `prefs` keys it owns. */ +const profileMetaKeys = ['location', 'jobTitle', 'pronouns'] as const +const profilePrefsKeys = ['timezone', 'dateFormat', 'timeFormat', 'appearance', 'cvd'] as const + +/** + * The square, in pixels, an avatar is resized to. The profile page and the account menu both display + * one at 180px; nothing displays one larger. + */ +const avatarSize = 180 + /** * Escape the LIKE wildcards `%` and `_` (and the escape character itself) so that a user-supplied * filter is matched literally. Values are still parameterized by the driver — this is about a `%` @@ -180,12 +224,7 @@ class Users { return null } - const groups = await WIKI.db - .select({ id: groupsTable.id, name: groupsTable.name }) - .from(userGroups) - .innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId)) - .where(eq(userGroups.userId, id)) - .orderBy(groupsTable.name) + const groups = await this.getUserGroups(id) const strategies = await WIKI.db.select().from(authenticationTable) const auth: UserAuthProvider[] = [] @@ -320,6 +359,142 @@ class Users { return (result.rowCount ?? 0) > 0 } + /** + * The profile of a single user, as shown on its own profile page. + * + * `meta` and `prefs` are free-form blobs, so every field is defaulted here rather than trusted to + * be present — a user created before a given key existed simply has none. + * + * @returns The profile, or null if no such user exists + */ + async getProfile(id: string): Promise { + const user = await this.getById(id) + if (!user) { + return null + } + const meta = (user.meta ?? {}) as Record + const prefs = (user.prefs ?? {}) as Record + return { + id: user.id, + name: user.name, + email: user.email, + hasAvatar: user.hasAvatar, + location: meta.location ?? '', + jobTitle: meta.jobTitle ?? '', + pronouns: meta.pronouns ?? '', + // -> An empty time zone / date format means "whatever the client resolves", which is what the + // profile page falls back to + timezone: prefs.timezone ?? '', + dateFormat: prefs.dateFormat ?? '', + timeFormat: prefs.timeFormat ?? '12h', + appearance: prefs.appearance ?? 'site', + cvd: prefs.cvd ?? 'none' + } + } + + /** + * Update a user's own profile fields, merging into the `meta` and `prefs` blobs rather than + * replacing them — an administrator's notes and any key this endpoint does not expose must survive + * a user saving its profile. + * + * @param patch Fields to change; omitted ones are left as they are + * @returns The updated profile, or null if no such user exists + */ + async updateProfile(id: string, patch: UserProfilePatch): Promise { + const user = await this.getById(id) + if (!user) { + return null + } + + const meta = { ...((user.meta ?? {}) as Record) } + const prefs = { ...((user.prefs ?? {}) as Record) } + for (const key of profileMetaKeys) { + if (patch[key] !== undefined) { + meta[key] = patch[key] + } + } + for (const key of profilePrefsKeys) { + if (patch[key] !== undefined) { + prefs[key] = patch[key] + } + } + + const values: UserPatch = { meta, prefs } + if (patch.name !== undefined) { + values.name = patch.name + } + await this.updateUser(id, values) + + return this.getProfile(id) + } + + /** + * A user's avatar, with the type its bytes say it is. + * + * The type is sniffed rather than stored: an avatar written while Sharp was installed is a JPEG, + * one written without it is whatever was uploaded, and nothing records which. Unrecognizable bytes + * are reported as JPEG, which is what every avatar stored by 2.x is. + * + * @returns The avatar, or null if this user has none + */ + async getAvatar(userId: string): Promise<{ data: Buffer; mime: string } | null> { + const rows = await WIKI.db + .select({ data: userAvatars.data }) + .from(userAvatars) + .where(eq(userAvatars.id, userId)) + .limit(1) + const data = rows[0]?.data + if (!data) { + return null + } + return { data, mime: detectImageMime(data) ?? 'image/jpeg' } + } + + /** + * Replace a user's avatar. + * + * Normalized to a square JPEG when the Sharp extension is installed — an avatar is displayed at one + * small size, so there is no reason to keep a multi-megabyte original around. Without Sharp the + * uploaded bytes are stored as they came in, which is why reading one sniffs the type. + * + * @param data The uploaded image, already known to be one of the supported formats + */ + async setAvatar(userId: string, data: Buffer): Promise { + const normalized = (await resizeImageToSquareJpeg(data, avatarSize)) ?? data + await WIKI.db + .insert(userAvatars) + .values({ id: userId, data: normalized }) + .onConflictDoUpdate({ target: userAvatars.id, set: { data: normalized } }) + await WIKI.db + .update(usersTable) + .set({ hasAvatar: true, updatedAt: sql`now()` }) + .where(eq(usersTable.id, userId)) + } + + /** + * Remove a user's avatar, leaving it to be rendered as initials again. + */ + async clearAvatar(userId: string): Promise { + await WIKI.db.delete(userAvatars).where(eq(userAvatars.id, userId)) + await WIKI.db + .update(usersTable) + .set({ hasAvatar: false, updatedAt: sql`now()` }) + .where(eq(usersTable.id, userId)) + } + + /** + * The groups a user belongs to, by name. Only the identity of each group — never its permissions or + * page rules, which a user has no business reading about itself. + */ + async getUserGroups(userId: string): Promise> { + return WIKI.db + .select({ id: groupsTable.id, name: groupsTable.name }) + .from(userGroups) + .innerJoin(groupsTable, eq(groupsTable.id, userGroups.groupId)) + .where(eq(userGroups.userId, userId)) + .orderBy(groupsTable.name) + } + /** * The IDs of the groups a user belongs to */ @@ -709,6 +884,45 @@ class Users { } } + /** + * Where to send a user after logging out. + * + * A group's own target wins over the site's, which is what the admin area promises: the site setting + * says it "can be overridden at the group level". With several groups the first one that names a + * target wins, the same arbitrary-but-stable rule the login redirect uses. + * + * @param userId The user logging out, or null for a request that was not logged in + * @param siteId The site being logged out of, if it is known + * @returns A path or URL, never empty — the site root when nothing is configured + */ + async getLogoutRedirect(userId: string | null, siteId?: string): Promise { + if (userId) { + const groups = await WIKI.db.query.users + .findFirst({ + columns: {}, + where: { + id: userId + }, + with: { + groups: { + columns: { + redirectOnLogout: true + } + } + } + }) + .then((r: any) => r?.groups ?? []) + for (const grp of groups as any[]) { + if (grp.redirectOnLogout && grp.redirectOnLogout !== '/') { + return grp.redirectOnLogout + } + } + } + + const site = siteId ? await WIKI.models.sites.getSiteById({ id: siteId }) : null + return site?.config?.auth?.logoutRedirect || '/' + } + async loginChangePassword( { strategyId, diff --git a/backend/modules/extensions/sharp/definition.yml b/backend/modules/extensions/sharp/definition.yml index 1043e510d..732b7e19c 100644 --- a/backend/modules/extensions/sharp/definition.yml +++ b/backend/modules/extensions/sharp/definition.yml @@ -1,10 +1,10 @@ key: sharp title: Sharp description: >- - Processes and transforms images. Required to generate thumbnails of uploaded images and to resize - site assets such as logos. + Processes and transforms images. Required to generate thumbnails of uploaded images, and used to + resize avatars and site assets such as logos. website: 'https://sharp.pixelplumbing.com' -# Detection: an optional dependency, so resolvable from the backend's node_modules when present +# Detection: a declared optional dependency of the backend, so normally already in its node_modules detect: type: module value: sharp @@ -12,4 +12,6 @@ detect: architectures: - x64 - arm64 -isInstallable: false +# Installable with npm, which is also how the admin area reinstalls a native binary that is missing or +# does not match this OS and architecture +isInstallable: true diff --git a/backend/package-lock.json b/backend/package-lock.json index 6cba79cb6..ccc051712 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -43,6 +43,7 @@ "poolifier": "5.3.2", "pug": "3.0.4", "semver": "7.8.4", + "sharp": "*", "uuid": "14.0.0" }, "devDependencies": { @@ -62,6 +63,9 @@ }, "engines": { "node": ">=26.0" + }, + "optionalDependencies": { + "sharp": "0.35.3" } }, "node_modules/@antfu/install-pkg": { @@ -417,6 +421,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1340,6 +1354,555 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@js-joda/core": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", @@ -3026,6 +3589,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctypes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", @@ -5345,6 +5918,69 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -5579,8 +6215,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-is": { "version": "2.0.1", diff --git a/backend/package.json b/backend/package.json index ffb9c2267..48a43de80 100644 --- a/backend/package.json +++ b/backend/package.json @@ -71,6 +71,9 @@ "semver": "7.8.4", "uuid": "14.0.0" }, + "optionalDependencies": { + "sharp": "0.35.3" + }, "devDependencies": { "@types/fs-extra": "11.0.4", "@types/js-yaml": "4.0.9", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 504d0a779..ae5778de3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -158,8 +158,15 @@ router.beforeEach(async (to, from) => { // GLOBAL EVENTS HANDLERS -EVENT_BUS.on('logout', () => { - router.push('/') +EVENT_BUS.on('logout', ({ redirect } = {}) => { + const target = redirect || '/' + // -> A group or the site can send logged out users to another site entirely, which the router cannot + // navigate to — and leaving the wiki means there is no point notifying anyone either + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) { + window.location.assign(target) + return + } + router.push(target) $q.notify({ type: 'positive', icon: 'las la-sign-out-alt', diff --git a/frontend/src/components/AccountMenu.vue b/frontend/src/components/AccountMenu.vue index 32b572f85..1101b16f2 100644 --- a/frontend/src/components/AccountMenu.vue +++ b/frontend/src/components/AccountMenu.vue @@ -8,7 +8,7 @@ q-btn.account-avbtn.q-ml-md(flat, round, dense, color='custom-color') v-else size='32px' ) - img(:src='`/_user/` + userStore.id + `/avatar`') + img(:src='`/_user/current/avatar`') q-menu.translucent-menu(auto-close) q-card(flat, style='width: 300px;', :dark='false') q-card-section(align='center') diff --git a/frontend/src/pages/AdminExtensions.vue b/frontend/src/pages/AdminExtensions.vue index cb31a8b81..144058730 100644 --- a/frontend/src/pages/AdminExtensions.vue +++ b/frontend/src/pages/AdminExtensions.vue @@ -164,9 +164,14 @@ async function install (ext) { if (!resp?.ok) { throw new Error(resp?.message || 'An unexpected error occured') } + // -> A reinstall repairs the files on disk, but a server that already failed to load the module + // keeps failing until it restarts — so that answer is a warning, not a success $q.notify({ - type: 'positive', - message: t('admin.extensions.installSuccess') + type: resp.restartRequired ? 'warning' : 'positive', + message: resp.restartRequired + ? t('admin.extensions.installRestartRequired') + : t('admin.extensions.installSuccess'), + timeout: resp.restartRequired ? 10000 : undefined }) // -> Re-detect rather than assume: the install is only done once the server can see the tool await load() diff --git a/frontend/src/pages/ProfileAvatar.vue b/frontend/src/pages/ProfileAvatar.vue index 6d2aea7c1..e3788d559 100644 --- a/frontend/src/pages/ProfileAvatar.vue +++ b/frontend/src/pages/ProfileAvatar.vue @@ -11,7 +11,7 @@ q-page.q-py-md(:style-fn='pageStyle') ) img( v-if='userStore.hasAvatar', - :src='`/_user/` + userStore.id + `/avatar?` + state.assetTimestamp' + :src='`/_user/current/avatar?` + state.assetTimestamp' ) q-icon( v-else, @@ -79,6 +79,9 @@ const state = reactive({ assetTimestamp: (new Date()).toISOString() }) +/** What the upload endpoint accepts. */ +const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] + const canEdit = computed(() => siteStore.features?.profile) // METHODS @@ -92,47 +95,41 @@ function pageStyle (offset, height) { async function uploadImage () { const input = document.createElement('input') input.type = 'file' + input.accept = acceptedTypes.join(',') input.onchange = async e => { + const file = e.target.files?.[0] + if (!file) { return } + // -> The file picker's filter is a suggestion the user can override, and the server checks the + // bytes anyway; saying so here beats a 415 with nothing to explain it + if (!acceptedTypes.includes(file.type)) { + $q.notify({ + type: 'negative', + message: t('profile.avatarUploadFailed'), + caption: t('profile.avatarUploadInvalidType') + }) + return + } state.loading++ try { - const resp = await APOLLO_CLIENT.mutate({ - context: { - uploadMode: true - }, - mutation: ` - mutation uploadUserAvatar ( - $id: UUID! - $image: Upload! - ) { - uploadUserAvatar ( - id: $id - image: $image - ) { - operation { - succeeded - message - } - } - } - `, - variables: { - id: userStore.id, - image: e.target.files[0] + // -> The image is the request body itself: the endpoint takes the raw file, not a form + const resp = await API_CLIENT.put('users/profile/avatar', { + body: file, + headers: { + 'content-type': file.type } - }) - if (resp?.data?.uploadUserAvatar?.operation?.succeeded) { - $q.notify({ - type: 'positive', - message: t('profile.avatarUploadSuccess') - }) - state.assetTimestamp = (new Date()).toISOString() - userStore.$patch({ - hasAvatar: true - }) - } else { - throw new Error(resp?.data?.uploadUserAvatar?.operation?.message || 'An unexpected error occured.') + }).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') } + $q.notify({ + type: 'positive', + message: t('profile.avatarUploadSuccess') + }) + state.assetTimestamp = (new Date()).toISOString() + userStore.$patch({ + hasAvatar: true + }) } catch (err) { $q.notify({ type: 'negative', @@ -149,37 +146,18 @@ async function uploadImage () { async function clearImage () { state.loading++ try { - const resp = await APOLLO_CLIENT.mutate({ - mutation: ` - mutation clearUserAvatar ( - $id: UUID! - ) { - clearUserAvatar ( - id: $id - ) { - operation { - succeeded - message - } - } - } - `, - variables: { - id: userStore.id - } - }) - if (resp?.data?.clearUserAvatar?.operation?.succeeded) { - $q.notify({ - type: 'positive', - message: t('profile.avatarClearSuccess') - }) - state.assetTimestamp = (new Date()).toISOString() - userStore.$patch({ - hasAvatar: false - }) - } else { - throw new Error(resp?.data?.uploadUserAvatar?.operation?.message || 'An unexpected error occured.') + const resp = await API_CLIENT.delete('users/profile/avatar').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') } + $q.notify({ + type: 'positive', + message: t('profile.avatarClearSuccess') + }) + state.assetTimestamp = (new Date()).toISOString() + userStore.$patch({ + hasAvatar: false + }) } catch (err) { $q.notify({ type: 'negative', diff --git a/frontend/src/pages/ProfileGroups.vue b/frontend/src/pages/ProfileGroups.vue index bf195b855..269cb7c45 100644 --- a/frontend/src/pages/ProfileGroups.vue +++ b/frontend/src/pages/ProfileGroups.vue @@ -35,16 +35,10 @@ import { useI18n } from 'vue-i18n' import { useMeta, useQuasar } from 'quasar' import { onMounted, reactive } from 'vue' -import { useUserStore } from '@/stores/user' - // QUASAR const $q = useQuasar() -// STORES - -const userStore = useUserStore() - // I18N const { t } = useI18n() @@ -52,7 +46,7 @@ const { t } = useI18n() // META useMeta({ - title: t('profile.avatar') + title: t('profile.groups') }) // DATA @@ -70,31 +64,15 @@ function pageStyle (offset, height) { } } +/** + * The groups come from the session's own endpoint rather than from `users/:id`: reading an arbitrary + * user requires `read:users`, which a regular user does not have. + */ async function fetchGroups () { state.loading++ try { - const respRaw = await APOLLO_CLIENT.query({ - query: ` - query getUserProfileGroups ( - $id: UUID! - ) { - userById ( - id: $id - ) { - id - groups { - id - name - } - } - } - `, - variables: { - id: userStore.id - }, - fetchPolicy: 'network-only' - }) - state.groups = respRaw.data?.userById?.groups ?? [] + const groups = await API_CLIENT.get('users/profile/groups').json() + state.groups = groups ?? [] } catch (err) { $q.notify({ type: 'negative', diff --git a/frontend/src/pages/ProfileInfo.vue b/frontend/src/pages/ProfileInfo.vue index 980e9c63e..b5e017280 100644 --- a/frontend/src/pages/ProfileInfo.vue +++ b/frontend/src/pages/ProfileInfo.vue @@ -100,6 +100,7 @@ q-page.q-py-md(:style-fn='pageStyle') dense options-dense :aria-label='t(`admin.general.defaultTimezone`)' + :readonly='!canEdit' ) q-separator.q-my-sm(inset) q-item @@ -116,6 +117,7 @@ q-page.q-py-md(:style-fn='pageStyle') dense :aria-label='t(`admin.general.defaultDateFormat`)' :options='dateFormats' + :readonly='!canEdit' ) q-separator.q-my-sm(inset) q-item @@ -131,6 +133,7 @@ q-page.q-py-md(:style-fn='pageStyle') no-caps toggle-color='primary' :options='timeFormats' + :disable='!canEdit' ) q-separator.q-my-sm(inset) q-item @@ -146,6 +149,7 @@ q-page.q-py-md(:style-fn='pageStyle') no-caps toggle-color='primary' :options='appearances' + :disable='!canEdit' ) .text-header.q-mt-lg {{t('profile.accessibility')}} q-item @@ -161,13 +165,15 @@ q-page.q-py-md(:style-fn='pageStyle') no-caps toggle-color='primary' :options='cvdChoices' + :disable='!canEdit' ) - .actions-bar.q-mt-lg + .actions-bar.q-mt-lg(v-if='canEdit') q-btn( icon='las la-check' unelevated :label='t(`common.actions.saveChanges`)' color='secondary' + :disable='state.loading > 0' @click='save' ) @@ -215,7 +221,8 @@ const state = reactive({ timeFormat: '12h', appearance: 'site', cvd: 'none' - } + }, + loading: 0 }) const dateFormats = [ @@ -253,53 +260,79 @@ function pageStyle (offset, height) { } } +/** + * The profile is read from the server rather than from the user store: the store only holds what the + * session carries (name, email, preferences), while the location / job title / pronouns live in the + * user's metadata and are not part of it. + */ +async function fetchProfile () { + state.loading++ + try { + const profile = await API_CLIENT.get('users/profile').json() + applyProfile(profile) + } catch (err) { + $q.notify({ + type: 'negative', + message: t('profile.infoLoadingFailed'), + caption: err.message + }) + } + state.loading-- +} + +function applyProfile (profile) { + state.config.name = profile.name || '' + state.config.email = profile.email || '' + state.config.location = profile.location || '' + state.config.jobTitle = profile.jobTitle || '' + state.config.pronouns = profile.pronouns || '' + // -> No stored time zone means "whatever the browser resolves" + state.config.timezone = profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '' + state.config.dateFormat = profile.dateFormat || '' + state.config.timeFormat = profile.timeFormat || '12h' + state.config.appearance = profile.appearance || 'site' + state.config.cvd = profile.cvd || 'none' +} + async function save () { $q.loading.show({ message: t('profile.saving') }) try { - const respRaw = await APOLLO_CLIENT.mutate({ - mutation: ` - mutation saveProfile ( - $name: String - $location: String - $jobTitle: String - $pronouns: String - $timezone: String - $dateFormat: String - $timeFormat: String - $appearance: UserSiteAppearance - $cvd: UserCvdChoices - ) { - updateProfile ( - name: $name - location: $location - jobTitle: $jobTitle - pronouns: $pronouns - timezone: $timezone - dateFormat: $dateFormat - timeFormat: $timeFormat - appearance: $appearance - cvd: $cvd - ) { - operation { - succeeded - message - } - } - } - `, - variables: state.config - }) - if (respRaw.data?.updateProfile?.operation?.succeeded) { - $q.notify({ - type: 'positive', - message: t('profile.saveSuccess') - }) - userStore.$patch(state.config) - } else { - throw new Error(respRaw.data?.updateProfile?.operation?.message || 'An unexpected error occured') + // -> The email is displayed read-only and cannot be changed here, so it is left out entirely + const resp = await API_CLIENT.put('users/profile', { + json: { + name: state.config.name, + location: state.config.location, + jobTitle: state.config.jobTitle, + pronouns: state.config.pronouns, + timezone: state.config.timezone, + dateFormat: state.config.dateFormat, + timeFormat: state.config.timeFormat, + appearance: state.config.appearance, + cvd: state.config.cvd + } + }).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured') } + if (resp.profile) { + applyProfile(resp.profile) + } + // -> Only the fields the store actually holds: the appearance and CVD choices are watched by the + // app shell, so saving them takes effect right away + userStore.$patch({ + name: state.config.name, + timezone: state.config.timezone, + dateFormat: state.config.dateFormat, + timeFormat: state.config.timeFormat, + appearance: state.config.appearance, + cvd: state.config.cvd + }) + $q.notify({ + type: 'positive', + message: t('profile.saveSuccess') + }) } catch (err) { $q.notify({ type: 'negative', @@ -313,15 +346,6 @@ async function save () { // MOUNTED onMounted(() => { - state.config.name = userStore.name || '' - state.config.email = userStore.email - state.config.location = userStore.location || '' - state.config.jobTitle = userStore.jobTitle || '' - state.config.pronouns = userStore.pronouns || '' - state.config.timezone = userStore.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '' - state.config.dateFormat = userStore.dateFormat || '' - state.config.timeFormat = userStore.timeFormat || '12h' - state.config.appearance = userStore.appearance || 'site' - state.config.cvd = userStore.cvd || 'none' + fetchProfile() }) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index cdb37ed87..22aa96f24 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -1,12 +1,48 @@ import { defineStore } from 'pinia' -import { jwtDecode } from 'jwt-decode' -import Cookies from 'js-cookie' -import { DateTime } from 'luxon' import { getAccessibleColor } from '@/helpers/accessibility' import { useSiteStore } from './site' +const pad = (value) => String(value).padStart(2, '0') + +/** + * Render the date part of a moment the way the user asked for it. + * + * The stored preference is one of a handful of explicit patterns, or an empty string meaning "whatever + * this locale does" — which is the only case a formatter can be left to decide on its own. + */ +function formatDatePart (zoned, dateFormat) { + switch (dateFormat) { + case 'DD/MM/YYYY': + return `${pad(zoned.day)}/${pad(zoned.month)}/${zoned.year}` + case 'DD.MM.YYYY': + return `${pad(zoned.day)}.${pad(zoned.month)}.${zoned.year}` + case 'MM/DD/YYYY': + return `${pad(zoned.month)}/${pad(zoned.day)}/${zoned.year}` + case 'YYYY-MM-DD': + return `${zoned.year}-${pad(zoned.month)}-${pad(zoned.day)}` + case 'YYYY/MM/DD': + return `${zoned.year}/${pad(zoned.month)}/${pad(zoned.day)}` + default: + // -> Numeric parts rather than `dateStyle: 'short'`, which abbreviates the year to two digits + return zoned.toLocaleString(undefined, { year: 'numeric', month: 'numeric', day: 'numeric' }) + } +} + +/** + * Render the time part. `hourCycle` rather than `hour12: false`, which some locales render as 24:00 + * where they mean 00:00. + */ +function formatTimePart (zoned, timeFormat) { + return zoned.toLocaleString( + undefined, + timeFormat === '24h' + ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } + : { hour: 'numeric', minute: '2-digit', hour12: true } + ) +} + export const useUserStore = defineStore('user', { state: () => ({ id: '10000000-0000-4000-8000-000000000001', @@ -25,6 +61,8 @@ export const useUserStore = defineStore('user', { profileLoaded: false }), getters: { + // -> Luxon format tokens, for the call sites that still format dates with luxon themselves. They + // retire with the last of those; `formatDateTime()` no longer goes through them. preferredDateFormat: (state) => { if (!state.dateFormat) { return 'D' @@ -68,9 +106,17 @@ export const useUserStore = defineStore('user', { }, async logout() { const siteStore = useSiteStore() - await API_CLIENT.get(`sites/${siteStore.id}/auth/logout`).json() + let redirect = '/' + try { + const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/logout`).json() + redirect = resp?.redirect || '/' + } catch (err) { + // -> Clear the client either way. Whatever went wrong, someone who clicked Logout must not be + // left looking at a page that still says they are signed in. + console.warn(err) + } this.setToGuest() - EVENT_BUS.emit('logout') + EVENT_BUS.emit('logout', { redirect }) }, setToGuest() { this.$patch({ @@ -84,6 +130,9 @@ export const useUserStore = defineStore('user', { appearance: 'site', cvd: 'none', permissions: [], + // -> Page permissions are only refetched on the next navigation, so leaving them would keep + // edit buttons on screen for a user who is no longer logged in + pagePermissions: [], authenticated: false, profileLoaded: false }) @@ -120,10 +169,36 @@ export const useUserStore = defineStore('user', { console.warn(`Failed to fetch page permissions at path ${path}!`) } }, + /** + * Format a moment as this user asked to see it: their date pattern, their 12h/24h choice, and their + * time zone. Word order comes from the locale, which is why `t` is passed in. + * + * @param date A `Temporal.Instant`, a `Date`, or a string one can be parsed from — what the API + * returns. Nullable columns like `lastLoginAt` are common, so nothing at all formats as + * an empty string rather than blowing up mid-render. + */ formatDateTime(t, date) { - return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat( - t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat }) - ) + if (!date) { + return '' + } + let instant = date + if (typeof date === 'string') { + instant = Temporal.Instant.from(date) + } else if (date instanceof Date) { + instant = date.toTemporalInstant() + } + // -> A preference set before the zone list changed, or none at all, falls back to this browser's + // zone rather than throwing in the middle of a table + let zoned + try { + zoned = instant.toZonedDateTimeISO(this.timezone || Temporal.Now.timeZoneId()) + } catch { + zoned = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()) + } + return t('common.datetime', { + date: formatDatePart(zoned, this.dateFormat), + time: formatTimePart(zoned, this.timeFormat) + }) } } })