From 2cf5b2569ee7005286bc3aab0f4cff4e32d6fd9c Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 25 Sep 2026 17:45:16 -0400 Subject: [PATCH] feat: resize images from file manager --- backend/api/assets.ts | 133 +++++ backend/helpers/images.ts | 118 +++++ backend/locales/en.json | 24 + backend/models/assets.ts | 103 +++- backend/models/auditLog.ts | 2 +- frontend/src/components/AssetResizeDialog.vue | 466 ++++++++++++++++++ frontend/src/components/FileManager.vue | 59 ++- frontend/src/components/shared/WSlider.vue | 210 ++++++++ frontend/src/components/shared/index.js | 2 + frontend/src/helpers/fileTypes.js | 7 +- 10 files changed, 1105 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/AssetResizeDialog.vue create mode 100644 frontend/src/components/shared/WSlider.vue diff --git a/backend/api/assets.ts b/backend/api/assets.ts index 2c65ab627..f09d66b5b 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -423,6 +423,139 @@ async function routes(app: FastifyInstance) { } ) + /** + * RESIZE IMAGE + */ + app.post<{ + Params: { siteId: string; assetId: string } + Body: { + width: number + height: number + quality?: number + mode: 'replace' | 'saveAs' + fileName?: string + } + }>( + '/sites/:siteId/assets/:assetId/resize', + { + /* + No route-level `permissions`: that hook reads the group-wide list, and asset permissions come + from a group's RULES, which address the folder the file is in. Checked below. + */ + schema: { + summary: 'Resize an image', + description: `Resizes a PNG, JPEG, WebP or GIF image to exactly \`width\` × \`height\` — sending a pair that does not keep the original ratio stretches it. Neither may exceed the original: an image can be made smaller, not larger. An animated GIF or WebP keeps its frames when saved as GIF or WebP, and is reduced to its first frame otherwise. SVG is refused, having no pixel size to change.\n\n\`replace\` writes the result over the file, which keeps its ID and its name, so every page showing it shows the new one. \`saveAs\` stores it as a new file named \`fileName\` in the same folder and locale, settled by the site's upload conflict behavior like any other upload, and encoded as whatever format that name's extension says.\n\nNeeds \`write:assets\` where the result is written, and \`read:assets\` on the original. Needs the Sharp extension, and answers 503 without it.`, + tags: ['Assets'], + params: assetIdParam, + body: { + type: 'object', + required: ['width', 'height', 'mode'], + properties: { + width: { + type: 'integer', + minimum: 1, + maximum: 65535 + }, + height: { + type: 'integer', + minimum: 1, + maximum: 65535 + }, + quality: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 80, + description: 'Only applies to JPEG and WebP. PNG and GIF are lossless and ignore it.' + }, + mode: { + type: 'string', + enum: ['replace', 'saveAs'] + }, + fileName: { + type: 'string', + minLength: 1, + maxLength: 255, + description: + 'Required for `saveAs`, including the extension. Sanitized, so the stored name may differ from the one sent.' + } + } + }, + response: { + 200: { + description: 'Image resized successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + asset: { $ref: 'Asset#' } + } + } + } + } + }, + async (req, reply) => { + // -> Whatever is written records who wrote it, and an API key is not a who + const authorId = req.session?.authenticated ? req.session.user?.id : null + if (!authorId) { + return reply.unauthorized('Resizing an image requires a logged in user.') + } + const isSaveAs = req.body.mode === 'saveAs' + if (isSaveAs && !req.body.fileName) { + return reply.badRequest('A file name is required to save the resized image as a new file.') + } + const source = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) + if (!source || !mayOnAsset(req, 'read:assets', source)) { + return reply.notFound('This asset does not exist.') + } + // -> Where the result lands: the file itself, or a new name beside it + const destination = { + folderPath: source.folderPath, + fileName: isSaveAs ? req.body.fileName! : source.fileName, + locale: source.locale + } + if (!mayOnAsset(req, 'write:assets', destination)) { + return reply.forbidden('You are not allowed to write a file here.') + } + const asset = await WIKI.models.assets.resizeImage({ + siteId: req.params.siteId, + id: req.params.assetId, + width: req.body.width, + height: req.body.height, + quality: req.body.quality ?? 80, + saveAs: isSaveAs ? req.body.fileName : null, + authorId + }) + if (!asset) { + return reply.notFound('This asset does not exist.') + } + + await audit(req, 'asset', 'resizeAsset', { + assetId: asset.id, + sourceAssetId: source.id, + siteId: req.params.siteId, + locale: source.locale, + folderPath: source.folderPath, + fileName: asset.fileName, + mode: req.body.mode, + width: req.body.width, + height: req.body.height, + previousWidth: source.width, + previousHeight: source.height + }) + + return { + ok: true, + message: 'Image resized successfully.', + asset + } + } + ) + /** * DELETE ASSET */ diff --git a/backend/helpers/images.ts b/backend/helpers/images.ts index 2fdf76a93..2bcd4060c 100644 --- a/backend/helpers/images.ts +++ b/backend/helpers/images.ts @@ -5,6 +5,8 @@ * normalizing it when the Sharp extension is available. */ +import { CustomError } from './common.ts' + /** The image formats an upload may use. */ export const imageMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const @@ -241,3 +243,119 @@ export async function readImageDimensions(data: Buffer): Promise(['gif', 'webp']) + +/** The formats that honour a quality setting. PNG and GIF are lossless, and ignore one. */ +const lossyFormats = new Set(['jpeg', 'webp']) + +/** What an image is resized to, and how it is encoded afterwards. */ +export type ImageResize = { + width: number + height: number + format: ResizableFormat + /** 1 to 100. Only consulted for a lossy format. */ + quality: number +} + +/** + * Resize an image to exactly the given size, using the Sharp extension. + * + * Unlike the helpers above, this is something a person asked for rather than something done on their + * behalf, so there is no quiet fallback to the original bytes: every way it can fail is thrown as an + * error the caller can show them. Sharp being absent is the one they can do something about, and is + * reported apart from an image Sharp could not read. + * + * Both dimensions are applied as given, so a pair that does not keep the ratio stretches the image — + * keeping it is the dialog's job, where the lock is. Neither may exceed the original, measured as a + * viewer sees it (after EXIF orientation, which is applied here too): enlarging costs bytes to look + * worse. + * + * An animated GIF or WebP keeps every frame, unless it is saved as a format that cannot animate. + * + * @throws `imageResizeSharpMissing` (503), `imageResizeUnreadable` (422) or `imageResizeEnlarge` (400) + */ +export async function resizeImage( + data: Buffer, + { width, height, format, quality }: ImageResize +): Promise { + const definition = WIKI.models.extensions.getDefinition('sharp') + if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) { + throw new CustomError( + 'imageResizeSharpMissing', + 'Resizing an image needs the Sharp extension, which is not installed on this server. An administrator can install it under Administration → Extensions.', + 503 + ) + } + const specifier = 'sharp' + let sharp: any + try { + ;({ default: sharp } = await import(specifier)) + } catch (err: any) { + WIKI.models.extensions.noteLoadFailure(specifier) + WIKI.logger.warn(`Could not load Sharp to resize an image: ${err.message}`) + throw new CustomError( + 'imageResizeSharpMissing', + 'Resizing an image needs the Sharp extension, which could not be loaded on this server. An administrator can reinstall it under Administration → Extensions, then restart the server.', + 503 + ) + } + + // -> Every frame where the result can hold them, so that resizing an animation does not quietly + // turn it into a still. PNG and JPEG cannot, and read all frames they would come out as every + // frame stacked into one tall picture, so they take the first. A still reads the same either way. + const input = { animated: animatedFormats.has(format), autoOrient: true } + let original: ImageDimensions | null + try { + const { width: w, height: h, pageHeight, orientation } = await sharp(data, input).metadata() + // -> An animation is stacked frame over frame, so its height as a whole is every frame's together + const frameHeight = pageHeight || h + original = + w && frameHeight + ? orientation && orientation >= 5 + ? { width: frameHeight, height: w } + : { width: w, height: frameHeight } + : null + } catch (err: any) { + WIKI.logger.debug(`Could not read an image to resize: ${err.message}`) + original = null + } + if (!original) { + throw new CustomError('imageResizeUnreadable', 'This image could not be read.', 422) + } + if (width > original.width || height > original.height) { + throw new CustomError( + 'imageResizeEnlarge', + `An image can only be made smaller: this one is ${original.width} × ${original.height}.` + ) + } + + try { + let pipeline = sharp(data, input).resize(width, height, { fit: 'fill' }) + if (format === 'jpeg') { + // -> JPEG has no alpha channel, and dropping one leaves whatever was transparent black + pipeline = pipeline.flatten({ background: '#ffffff' }) + } + return await pipeline.toFormat(format, lossyFormats.has(format) ? { quality } : {}).toBuffer() + } catch (err: any) { + WIKI.logger.warn(`Could not resize an image: ${err.message}`) + throw new CustomError('imageResizeUnreadable', 'This image could not be resized.', 422) + } +} diff --git a/backend/locales/en.json b/backend/locales/en.json index d367bafbe..75a9552a3 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -195,6 +195,7 @@ "admin.audit.actions.requestPasswordReset": "Requested a password reset", "admin.audit.actions.resetPassword": "Reset a password from an emailed link", "admin.audit.actions.resetUserPassword": "Set a user's password", + "admin.audit.actions.resizeAsset": "Resized an image", "admin.audit.actions.restorePage": "Restored a deleted page", "admin.audit.actions.retryJob": "Retried a job", "admin.audit.actions.revokeApiKey": "Revoked an API key", @@ -2678,6 +2679,28 @@ "fileman.renameAssetInvalid": "Asset name is invalid.", "fileman.renameFolderInvalidData": "One or more fields are invalid.", "fileman.renameFolderSuccess": "Folder renamed successfully.", + "fileman.resizeImage": "Resize Image", + "fileman.resizeImageAction": "Resize", + "fileman.resizeImageDimensionInvalid": "Must be a whole number from 1 to {max}.", + "fileman.resizeImageFailed": "Failed to resize image.", + "fileman.resizeImageFileName": "New File Name", + "fileman.resizeImageFileNameHint": "Including the extension, which decides the format it is saved as.", + "fileman.resizeImageFileNameInvalid": "Must end in .png, .jpg, .jpeg, .webp or .gif.", + "fileman.resizeImageFileNameMissing": "Enter a file name.", + "fileman.resizeImageHeight": "Height", + "fileman.resizeImageInvalidData": "One or more fields are invalid.", + "fileman.resizeImageLockRatio": "Lock aspect ratio", + "fileman.resizeImageMeasureFailed": "Could not read the size of this image.", + "fileman.resizeImageOriginal": "Original size: {width} × {height} px. An image can only be made smaller.", + "fileman.resizeImagePercent": "Scale", + "fileman.resizeImagePercentInvalid": "Must be more than 0 and at most 100.", + "fileman.resizeImageQuality": "Quality", + "fileman.resizeImageQualityLossless": "{format} is lossless, so quality does not apply.", + "fileman.resizeImageReplace": "Replace existing file", + "fileman.resizeImageSaveAs": "Save as a new file", + "fileman.resizeImageSuccess": "Image resized successfully.", + "fileman.resizeImageUnlockRatio": "Unlock aspect ratio", + "fileman.resizeImageWidth": "Width", "fileman.restoreFailed": "Failed to restore the page.", "fileman.restorePageTo": "Restore Page To...", "fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.", @@ -2697,6 +2720,7 @@ "fileman.viewOptions": "View Options", "fileman.visualPageType": "Visual Page", "fileman.wavFileType": "WAV Audio File", + "fileman.webpFileType": "WebP Image", "fileman.wmaFileType": "WMA Audio File", "fileman.wmvFileType": "WMV Video File", "fileman.woff2FileType": "Web Open Font File", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index 1772d8dc2..ffd531d50 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -9,7 +9,12 @@ import { encodeTreePath, normalizeFolderPath } from '../helpers/common.ts' -import { makeImageThumbnail, readImageDimensions } from '../helpers/images.ts' +import { + makeImageThumbnail, + readImageDimensions, + resizableFormats, + resizeImage +} from '../helpers/images.ts' import type { ImageDimensions } from '../helpers/images.ts' import type { Readable } from 'node:stream' import type { DeletedEntry } from './tree.ts' @@ -613,6 +618,102 @@ class Assets { ) } + /** + * Resize an image, either over itself or into a new file beside it. + * + * Over itself is `replace`, the same as an upload landing on the name under the `overwrite` + * behavior: the ID, the name and the place stay, so every page showing the image now shows the + * smaller one. A new file is an upload into the same folder and locale, and is settled by the site's + * conflict behavior exactly as a file dropped there would be — so naming it after the original + * replaces it too where the site overwrites, and is refused where it rejects. + * + * What the result is encoded as comes from the name it is stored under, which is how a PNG saved as + * `.webp` becomes one. SVG is refused outright: it is markup, and has no pixels to resize. + * + * @param saveAs What to call the new file. Absent to resize the image in place. + * @returns The resized asset — for a new file, the one the upload produced — or null if there is no + * such asset on this site + * @throws `imageResizeUnsupported` for a file that is not a raster image, or a name that is not one + * of the formats it can be written as, plus whatever `resizeImage` and `upload` throw + */ + async resizeImage({ + siteId, + id, + width, + height, + quality, + saveAs, + authorId + }: { + siteId: string + id: string + width: number + height: number + quality: number + saveAs?: string | null + authorId: string + }): Promise { + const source = await this.getAsset(siteId, id) + if (!source) { + return null + } + if (!Object.hasOwn(resizableFormats, source.fileExt)) { + throw new CustomError( + 'imageResizeUnsupported', + source.fileExt === 'svg' + ? 'An SVG is a vector image, so it has no pixel size to change.' + : 'Only PNG, JPEG, WebP and GIF images can be resized.' + ) + } + const targetName = saveAs ? sanitizeFileName(saveAs) : source.fileName + if (!targetName) { + throw new CustomError('assetInvalidFileName', 'This file name cannot be used.') + } + const targetExt = extensionOf(targetName) + const format = resizableFormats[targetExt as keyof typeof resizableFormats] + if (!format) { + throw new CustomError( + 'imageResizeUnsupported', + 'A resized image has to be saved as .png, .jpg, .jpeg, .webp or .gif.' + ) + } + + const content = await this.getContent(id) + if (!content) { + throw new CustomError('assetNoContent', 'This file has no content to resize.', 404) + } + const data = await resizeImage(content.data, { width, height, format, quality }) + + if (saveAs) { + return this.upload({ + siteId, + locale: source.locale, + folderPath: source.folderPath, + fileName: targetName, + data, + authorId + }) + } + + const mimeType = mime.getType(targetName) ?? content.mimeType + return this.replace({ + id, + siteId, + locale: source.locale, + folderPath: source.folderPath, + fileName: source.fileName, + title: source.title, + fileExt: targetExt, + kind: 'image', + mimeType, + data, + preview: await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height), + // -> Known rather than measured: the resize wrote exactly this, the right way up + dimensions: { width, height }, + authorId + }) + } + /** * An asset's metadata, without its bytes. Null if there is no such asset on this site. */ diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 09be17200..d7a673790 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -48,7 +48,7 @@ export const AUDIT_ACTIONS = { 'setFolderColor', 'deleteFolder' ], - asset: ['uploadAsset', 'updateAsset', 'deleteAsset'], + asset: ['uploadAsset', 'updateAsset', 'resizeAsset', 'deleteAsset'], comment: ['createComment', 'updateComment', 'deleteComment'], auth: [ 'login', diff --git a/frontend/src/components/AssetResizeDialog.vue b/frontend/src/components/AssetResizeDialog.vue new file mode 100644 index 000000000..07ff67248 --- /dev/null +++ b/frontend/src/components/AssetResizeDialog.vue @@ -0,0 +1,466 @@ + + + diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index ee0c411d9..7e6d649f8 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -566,25 +566,34 @@ {{ t(`common.actions.view`) }} - - + + + + Edit Image... + + + + + + + {{ t(`fileman.resizeImage`) }}... + import('@/components/AssetResizeDialog.vue')), + componentProps: { + assetId: item.id, + fileName: item.fileName, + width: item.width ?? null, + height: item.height ?? null + } + }).onOk(async () => { + await loadTree({ parentId: state.currentFolderId }) + }) +} + function delAsset(assetId, assetName) { dialog({ component: defineAsyncComponent(() => import('@/components/AssetDeleteDialog.vue')), diff --git a/frontend/src/components/shared/WSlider.vue b/frontend/src/components/shared/WSlider.vue new file mode 100644 index 000000000..e5d011751 --- /dev/null +++ b/frontend/src/components/shared/WSlider.vue @@ -0,0 +1,210 @@ + + + diff --git a/frontend/src/components/shared/index.js b/frontend/src/components/shared/index.js index 1b9aa0480..510e922b4 100644 --- a/frontend/src/components/shared/index.js +++ b/frontend/src/components/shared/index.js @@ -53,6 +53,7 @@ import WScrollArea from './WScrollArea.vue' import WSelect from './WSelect.vue' import WSeparator from './WSeparator.vue' import WSignal from './WSignal.vue' +import WSlider from './WSlider.vue' import WSpace from './WSpace.vue' import WSpinner from './WSpinner.vue' import WTab from './WTab.vue' @@ -113,6 +114,7 @@ export const sharedComponents = { WSelect, WSeparator, WSignal, + WSlider, WSpace, WSpinner, WTab, diff --git a/frontend/src/helpers/fileTypes.js b/frontend/src/helpers/fileTypes.js index 056e7fb13..298e7f4d3 100644 --- a/frontend/src/helpers/fileTypes.js +++ b/frontend/src/helpers/fileTypes.js @@ -56,7 +56,8 @@ export default { icon: 'img:/_assets/icons/fluent-folder.svg' }, gif: { - icon: 'img:/_assets/icons/color-gif.svg' + icon: 'img:/_assets/icons/color-gif.svg', + imageEdit: true }, gz: { icon: 'img:/_assets/icons/color-archive.svg' @@ -156,6 +157,10 @@ export default { wav: { icon: 'img:/_assets/icons/color-wav.svg' }, + webp: { + icon: 'img:/_assets/icons/color-image-file.svg', + imageEdit: true + }, wma: { icon: 'img:/_assets/icons/color-audio-file.svg' },