mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
196 lines
7.6 KiB
196 lines
7.6 KiB
/**
|
|
* 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]
|
|
|
|
/**
|
|
* SVG, which is markup rather than an image format and so is handled apart from the raster ones
|
|
* everywhere: it is recognized by reading it, it cannot be resized or re-encoded, and serving one
|
|
* back means serving a document a browser will happily execute scripts from.
|
|
*/
|
|
export const svgMimeType = 'image/svg+xml'
|
|
|
|
/**
|
|
* Recognize SVG markup.
|
|
*
|
|
* There is no magic number to match: an SVG may open with a byte order mark, an XML declaration, a
|
|
* doctype or comments before the root element ever appears. So the start of the file is read as text
|
|
* and the root element looked for — enough to tell an SVG from a file claiming to be one, which is
|
|
* all this decides.
|
|
*/
|
|
export function detectSvg(data: Buffer): boolean {
|
|
return /<svg[\s>]/i.test(data.subarray(0, 1024).toString('utf8'))
|
|
}
|
|
|
|
/**
|
|
* 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<Buffer | null> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/** How an uploaded image is brought down to the size and format it will be served at. */
|
|
export type ImageNormalization = {
|
|
width: number
|
|
height: number
|
|
/**
|
|
* `cover` crops to the target aspect ratio, for an image whose frame is fixed — a favicon, a
|
|
* background. `inside` fits within the box instead, for one whose own proportions matter, such as
|
|
* a logo that may be any shape.
|
|
*/
|
|
fit: 'cover' | 'inside'
|
|
/** `webp` for anything displayed by the app itself; `png` where the widest support is worth the
|
|
* bytes, as it is for a favicon. Both keep transparency, which a logo usually depends on. */
|
|
format: 'webp' | 'png'
|
|
}
|
|
|
|
/**
|
|
* Re-encode an image to the given size and format, using the Sharp extension.
|
|
*
|
|
* Never enlarges: upscaling a small upload would cost bytes to look worse. So the result is at most
|
|
* the requested size, and an image already smaller than the box is only re-encoded.
|
|
*
|
|
* @returns The re-encoded image, or null if Sharp is not usable on this system
|
|
*/
|
|
export async function normalizeImage(
|
|
data: Buffer,
|
|
{ width, height, fit, format }: ImageNormalization
|
|
): Promise<Buffer | null> {
|
|
const definition = WIKI.models.extensions.getDefinition('sharp')
|
|
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
|
|
return null
|
|
}
|
|
const specifier = 'sharp'
|
|
// -> Loading Sharp and running it are kept apart, as they are for a thumbnail: the upload may simply
|
|
// be an image Sharp cannot read, which must not be recorded as Sharp itself being broken
|
|
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 re-encode an image: ${err.message}`)
|
|
return null
|
|
}
|
|
try {
|
|
const resized = sharp(data).resize(width, height, {
|
|
fit,
|
|
position: 'centre',
|
|
withoutEnlargement: true
|
|
})
|
|
return await (
|
|
format === 'png' ? resized.png({ compressionLevel: 9 }) : resized.webp({ quality: 80 })
|
|
).toBuffer()
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Could not re-encode an uploaded image: ${err.message}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shrink an image to a WebP thumbnail, using the Sharp extension.
|
|
*
|
|
* Unlike an avatar, a thumbnail has no fallback: a file manager that cannot make one simply shows the
|
|
* file type icon instead, so null here is an ordinary outcome rather than a degraded one.
|
|
*
|
|
* @returns The thumbnail, or null if Sharp is not usable on this system or these bytes are not an
|
|
* image it can read
|
|
*/
|
|
export async function makeImageThumbnail(
|
|
data: Buffer,
|
|
width: number,
|
|
height: number
|
|
): Promise<Buffer | null> {
|
|
const definition = WIKI.models.extensions.getDefinition('sharp')
|
|
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
|
|
return null
|
|
}
|
|
const specifier = 'sharp'
|
|
// -> Loading Sharp and running it are kept apart here, unlike above: whatever a user uploaded may
|
|
// simply not be an image Sharp can read, and that must not be recorded as Sharp itself being
|
|
// broken for the rest of the process
|
|
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 generate a thumbnail: ${err.message}`)
|
|
return null
|
|
}
|
|
try {
|
|
return await sharp(data)
|
|
.resize(width, height, { fit: 'cover', position: 'centre', withoutEnlargement: true })
|
|
.webp({ quality: 80 })
|
|
.toBuffer()
|
|
} catch (err: any) {
|
|
WIKI.logger.debug(`Could not generate a thumbnail for an upload: ${err.message}`)
|
|
return null
|
|
}
|
|
}
|