Passthrough SVG images in image resizing (#3224)

This commit is contained in:
Samy Pessé
2025-05-08 18:57:50 +02:00
committed by GitHub
parent cb5598dc19
commit 7d7806df30
5 changed files with 40 additions and 23 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": minor
"gitbook": minor
---
Pass SVG images through image resizing without resizing them to serve them from optimal host.
@@ -1,4 +1,8 @@
import { checkIsHttpURL } from '@/lib/urls'; export enum SizableImageAction {
Resize = 'resize',
Skip = 'skip',
Passthrough = 'passthrough',
}
/** /**
* Check if an image URL is resizable. * Check if an image URL is resizable.
@@ -6,22 +10,24 @@ import { checkIsHttpURL } from '@/lib/urls';
* Skip it for SVGs. * Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion). * Skip it for GitBook images (to avoid recursion).
*/ */
export function checkIsSizableImageURL(input: string): boolean { export function checkIsSizableImageURL(input: string): SizableImageAction {
if (!URL.canParse(input)) { if (!URL.canParse(input)) {
return false; return SizableImageAction.Skip;
}
if (input.includes('/~gitbook/image')) {
return false;
} }
const parsed = new URL(input); const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false; return SizableImageAction.Skip;
} }
if (!checkIsHttpURL(parsed)) { if (parsed.hostname === 'localhost') {
return false; return SizableImageAction.Skip;
}
if (parsed.pathname.includes('/~gitbook/image')) {
return SizableImageAction.Skip;
}
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return SizableImageAction.Passthrough;
} }
return true; return SizableImageAction.Resize;
} }
@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env'; import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links'; import type { GitBookLinker } from '../links';
import { checkIsSizableImageURL } from './checkIsSizableImageURL'; import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer'; import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures'; import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types'; import type { ImageResizer } from './types';
@@ -24,7 +24,7 @@ export function createImageResizer({
return { return {
getResizedImageURL: (urlInput) => { getResizedImageURL: (urlInput) => {
if (!checkIsSizableImageURL(urlInput)) { if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
return null; return null;
} }
@@ -64,7 +64,7 @@ export function createImageResizer({
}, },
getImageSize: async (input, options) => { getImageSize: async (input, options) => {
if (!checkIsSizableImageURL(input)) { if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null; return null;
} }
@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import assertNever from 'assert-never'; import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env'; import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { checkIsSizableImageURL } from '../checkIsSizableImageURL'; import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { resizeImageWithCDNCgi } from './cdn-cgi'; import { resizeImageWithCDNCgi } from './cdn-cgi';
import { resizeImageWithCFFetch } from './cf-fetch'; import { resizeImageWithCFFetch } from './cf-fetch';
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types'; import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
@@ -13,7 +13,7 @@ export async function getImageSize(
input: string, input: string,
defaultSize: Partial<CloudflareImageOptions> = {} defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> { ): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) { if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null; return null;
} }
@@ -48,13 +48,17 @@ export async function resizeImage(
signal?: AbortSignal; signal?: AbortSignal;
} }
): Promise<Response> { ): Promise<Response> {
const parsed = new URL(input); const action = checkIsSizableImageURL(input);
if (parsed.protocol === 'data:') { if (action === SizableImageAction.Skip) {
throw new Error('Cannot resize data: URLs'); throw new Error(
'Cannot resize this image, this function should have never been called on this url'
);
} }
if (parsed.hostname === 'localhost') { if (action === SizableImageAction.Passthrough) {
throw new Error('Cannot resize localhost URLs'); return fetch(input, {
signal: options.signal,
});
} }
switch (GITBOOK_IMAGE_RESIZE_MODE) { switch (GITBOOK_IMAGE_RESIZE_MODE) {
+2 -1
View File
@@ -2,6 +2,7 @@ import {
CURRENT_SIGNATURE_VERSION, CURRENT_SIGNATURE_VERSION,
type CloudflareImageOptions, type CloudflareImageOptions,
type SignatureVersion, type SignatureVersion,
SizableImageAction,
checkIsSizableImageURL, checkIsSizableImageURL,
isSignatureVersion, isSignatureVersion,
parseImageAPIURL, parseImageAPIURL,
@@ -40,7 +41,7 @@ export async function serveResizedImage(
// Check again if the image can be sized, even though we checked when rendering the Image component // Check again if the image can be sized, even though we checked when rendering the Image component
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain // Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
// Also prevent infinite redirects. // Also prevent infinite redirects.
if (!checkIsSizableImageURL(url)) { if (checkIsSizableImageURL(url) === SizableImageAction.Skip) {
return new Response('Invalid url parameter', { status: 400 }); return new Response('Invalid url parameter', { status: 400 });
} }