feat(assets): support remote images (#7778)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Princesseuh <princssdev@gmail.com>
Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
Julien Barbay 2023-08-17 18:10:50 +07:00 committed by GitHub
parent 2145960472
commit d6b4943764
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 657 additions and 190 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/vercel': patch
---
Update image support to work with latest version of Astro

View file

@ -0,0 +1,27 @@
---
'astro': patch
---
Added support for optimizing remote images from authorized sources when using `astro:assets`. This comes with two new parameters to specify which domains (`image.domains`) and host patterns (`image.remotePatterns`) are authorized for remote images.
For example, the following configuration will only allow remote images from `astro.build` to be optimized:
```ts
// astro.config.mjs
export default defineConfig({
image: {
domains: ["astro.build"],
}
});
```
The following configuration will only allow remote images from HTTPS hosts:
```ts
// astro.config.mjs
export default defineConfig({
image: {
remotePatterns: [{ protocol: "https" }],
}
});
```

View file

@ -147,6 +147,7 @@
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"html-escaper": "^3.0.3",
"http-cache-semantics": "^4.1.1",
"js-yaml": "^4.1.0",
"kleur": "^4.1.4",
"magic-string": "^0.30.2",
@ -186,6 +187,7 @@
"@types/estree": "^0.0.51",
"@types/hast": "^2.3.4",
"@types/html-escaper": "^3.0.0",
"@types/http-cache-semantics": "^4.0.1",
"@types/js-yaml": "^4.0.5",
"@types/mime": "^2.0.3",
"@types/mocha": "^9.1.1",

View file

@ -13,6 +13,7 @@ import type { AddressInfo } from 'node:net';
import type * as rollup from 'rollup';
import type { TsConfigJson } from 'tsconfig-resolver';
import type * as vite from 'vite';
import type { RemotePattern } from '../assets/utils/remotePattern';
import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types';
import type { AstroConfigType } from '../core/config';
@ -43,6 +44,7 @@ export type {
ImageQualityPreset,
ImageTransform,
} from '../assets/types';
export type { RemotePattern } from '../assets/utils/remotePattern';
export type { SSRManifest } from '../core/app/types';
export type { AstroCookies } from '../core/cookies';
@ -366,10 +368,10 @@ export interface ViteUserConfig extends vite.UserConfig {
ssr?: vite.SSROptions;
}
export interface ImageServiceConfig {
export interface ImageServiceConfig<T extends Record<string, any> = Record<string, any>> {
// eslint-disable-next-line @typescript-eslint/ban-types
entrypoint: 'astro/assets/services/sharp' | 'astro/assets/services/squoosh' | (string & {});
config?: Record<string, any>;
config?: T;
}
/**
@ -1010,6 +1012,68 @@ export interface AstroUserConfig {
* ```
*/
service: ImageServiceConfig;
/**
* @docs
* @name image.domains (Experimental)
* @type {string[]}
* @default `{domains: []}`
* @version 2.10.10
* @description
* Defines a list of permitted image source domains for local image optimization. No other remote images will be optimized by Astro.
*
* This option requires an array of individual domain names as strings. Wildcards are not permitted. Instead, use [`image.remotePatterns`](#imageremotepatterns-experimental) to define a list of allowed source URL patterns.
*
* ```js
* // astro.config.mjs
* {
* image: {
* // Example: Allow remote image optimization from a single domain
* domains: ['astro.build'],
* },
* }
* ```
*/
domains?: string[];
/**
* @docs
* @name image.remotePatterns (Experimental)
* @type {RemotePattern[]}
* @default `{remotePatterns: []}`
* @version 2.10.10
* @description
* Defines a list of permitted image source URL patterns for local image optimization.
*
* `remotePatterns` can be configured with four properties:
* 1. protocol
* 2. hostname
* 3. port
* 4. pathname
*
* ```js
* {
* image: {
* // Example: allow processing all images from your aws s3 bucket
* remotePatterns: [{
* protocol: 'https',
* hostname: '**.amazonaws.com',
* }],
* },
* }
* ```
*
* You can use wildcards to define the permitted `hostname` and `pathname` values as described below. Otherwise, only the exact values provided will be configured:
* `hostname`:
* - Start with '**.' to allow all subdomains ('endsWith').
* - Start with '*.' to allow only one level of subdomain.
*
* `pathname`:
* - End with '/**' to allow all sub-routes ('startsWith').
* - End with '/*' to allow only one level of sub-route.
*/
remotePatterns?: Partial<RemotePattern>[];
};
/**

View file

@ -0,0 +1,174 @@
import fs, { readFileSync } from 'node:fs';
import { basename, join } from 'node:path/posix';
import type { StaticBuildOptions } from '../../core/build/types.js';
import { warn } from '../../core/logger/core.js';
import { prependForwardSlash } from '../../core/path.js';
import { isServerLikeOutput } from '../../prerender/utils.js';
import { getConfiguredImageService, isESMImportedImage } from '../internal.js';
import type { LocalImageService } from '../services/service.js';
import type { ImageMetadata, ImageTransform } from '../types.js';
import { loadRemoteImage, type RemoteCacheEntry } from './remote.js';
interface GenerationDataUncached {
cached: false;
weight: {
before: number;
after: number;
};
}
interface GenerationDataCached {
cached: true;
}
type GenerationData = GenerationDataUncached | GenerationDataCached;
export async function generateImage(
buildOpts: StaticBuildOptions,
options: ImageTransform,
filepath: string
): Promise<GenerationData | undefined> {
let useCache = true;
const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir);
// Ensure that the cache directory exists
try {
await fs.promises.mkdir(assetsCacheDir, { recursive: true });
} catch (err) {
warn(
buildOpts.logging,
'astro:assets',
`An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}`
);
useCache = false;
}
let serverRoot: URL, clientRoot: URL;
if (isServerLikeOutput(buildOpts.settings.config)) {
serverRoot = buildOpts.settings.config.build.server;
clientRoot = buildOpts.settings.config.build.client;
} else {
serverRoot = buildOpts.settings.config.outDir;
clientRoot = buildOpts.settings.config.outDir;
}
const isLocalImage = isESMImportedImage(options.src);
const finalFileURL = new URL('.' + filepath, clientRoot);
const finalFolderURL = new URL('./', finalFileURL);
// For remote images, instead of saving the image directly, we save a JSON file with the image data and expiration date from the server
const cacheFile = basename(filepath) + (isLocalImage ? '' : '.json');
const cachedFileURL = new URL(cacheFile, assetsCacheDir);
await fs.promises.mkdir(finalFolderURL, { recursive: true });
// Check if we have a cached entry first
try {
if (isLocalImage) {
await fs.promises.copyFile(cachedFileURL, finalFileURL);
return {
cached: true,
};
} else {
const JSONData = JSON.parse(readFileSync(cachedFileURL, 'utf-8')) as RemoteCacheEntry;
// If the cache entry is not expired, use it
if (JSONData.expires < Date.now()) {
await fs.promises.writeFile(finalFileURL, Buffer.from(JSONData.data, 'base64'));
return {
cached: true,
};
}
}
} catch (e: any) {
if (e.code !== 'ENOENT') {
throw new Error(`An error was encountered while reading the cache file. Error: ${e}`);
}
// If the cache file doesn't exist, just move on, and we'll generate it
}
// The original filepath or URL from the image transform
const originalImagePath = isLocalImage
? (options.src as ImageMetadata).src
: (options.src as string);
let imageData;
let resultData: { data: Buffer | undefined; expires: number | undefined } = {
data: undefined,
expires: undefined,
};
// If the image is local, we can just read it directly, otherwise we need to download it
if (isLocalImage) {
imageData = await fs.promises.readFile(
new URL(
'.' +
prependForwardSlash(
join(buildOpts.settings.config.build.assets, basename(originalImagePath))
),
serverRoot
)
);
} else {
const remoteImage = await loadRemoteImage(originalImagePath);
resultData.expires = remoteImage.expires;
imageData = remoteImage.data;
}
const imageService = (await getConfiguredImageService()) as LocalImageService;
resultData.data = (
await imageService.transform(
imageData,
{ ...options, src: originalImagePath },
buildOpts.settings.config.image
)
).data;
try {
// Write the cache entry
if (useCache) {
if (isLocalImage) {
await fs.promises.writeFile(cachedFileURL, resultData.data);
} else {
await fs.promises.writeFile(
cachedFileURL,
JSON.stringify({
data: Buffer.from(resultData.data).toString('base64'),
expires: resultData.expires,
})
);
}
}
} catch (e) {
warn(
buildOpts.logging,
'astro:assets',
`An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}`
);
} finally {
// Write the final file
await fs.promises.writeFile(finalFileURL, resultData.data);
}
return {
cached: false,
weight: {
// Divide by 1024 to get size in kilobytes
before: Math.trunc(imageData.byteLength / 1024),
after: Math.trunc(Buffer.from(resultData.data).byteLength / 1024),
},
};
}
export function getStaticImageList(): Iterable<
[string, { path: string; options: ImageTransform }]
> {
if (!globalThis?.astroAsset?.staticImages) {
return [];
}
return globalThis.astroAsset.staticImages?.entries();
}

View file

@ -0,0 +1,48 @@
import CachePolicy from 'http-cache-semantics';
export type RemoteCacheEntry = { data: string; expires: number };
export async function loadRemoteImage(src: string) {
const req = new Request(src);
const res = await fetch(req);
if (!res.ok) {
throw new Error(
`Failed to load remote image ${src}. The request did not return a 200 OK response. (received ${res.status}))`
);
}
// calculate an expiration date based on the response's TTL
const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res));
const expires = policy.storable() ? policy.timeToLive() : 0;
return {
data: Buffer.from(await res.arrayBuffer()),
expires: Date.now() + expires,
};
}
function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request {
let headers: CachePolicy.Headers = {};
// Be defensive here due to a cookie header bug in node@18.14.1 + undici
try {
headers = Object.fromEntries(_headers.entries());
} catch {}
return {
method,
url,
headers,
};
}
function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response {
let headers: CachePolicy.Headers = {};
// Be defensive here due to a cookie header bug in node@18.14.1 + undici
try {
headers = Object.fromEntries(_headers.entries());
} catch {}
return {
status,
headers,
};
}

View file

@ -1,127 +0,0 @@
import fs from 'node:fs';
import { basename, join } from 'node:path/posix';
import type { StaticBuildOptions } from '../core/build/types.js';
import { warn } from '../core/logger/core.js';
import { prependForwardSlash } from '../core/path.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { getConfiguredImageService, isESMImportedImage } from './internal.js';
import type { LocalImageService } from './services/service.js';
import type { ImageTransform } from './types.js';
interface GenerationDataUncached {
cached: false;
weight: {
before: number;
after: number;
};
}
interface GenerationDataCached {
cached: true;
}
type GenerationData = GenerationDataUncached | GenerationDataCached;
export async function generateImage(
buildOpts: StaticBuildOptions,
options: ImageTransform,
filepath: string
): Promise<GenerationData | undefined> {
if (!isESMImportedImage(options.src)) {
return undefined;
}
let useCache = true;
const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir);
// Ensure that the cache directory exists
try {
await fs.promises.mkdir(assetsCacheDir, { recursive: true });
} catch (err) {
warn(
buildOpts.logging,
'astro:assets',
`An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}`
);
useCache = false;
}
let serverRoot: URL, clientRoot: URL;
if (isServerLikeOutput(buildOpts.settings.config)) {
serverRoot = buildOpts.settings.config.build.server;
clientRoot = buildOpts.settings.config.build.client;
} else {
serverRoot = buildOpts.settings.config.outDir;
clientRoot = buildOpts.settings.config.outDir;
}
const finalFileURL = new URL('.' + filepath, clientRoot);
const finalFolderURL = new URL('./', finalFileURL);
const cachedFileURL = new URL(basename(filepath), assetsCacheDir);
try {
await fs.promises.copyFile(cachedFileURL, finalFileURL);
return {
cached: true,
};
} catch (e) {
// no-op
}
// The original file's path (the `src` attribute of the ESM imported image passed by the user)
const originalImagePath = options.src.src;
const fileData = await fs.promises.readFile(
new URL(
'.' +
prependForwardSlash(
join(buildOpts.settings.config.build.assets, basename(originalImagePath))
),
serverRoot
)
);
const imageService = (await getConfiguredImageService()) as LocalImageService;
const resultData = await imageService.transform(
fileData,
{ ...options, src: originalImagePath },
buildOpts.settings.config.image.service.config
);
await fs.promises.mkdir(finalFolderURL, { recursive: true });
if (useCache) {
try {
await fs.promises.writeFile(cachedFileURL, resultData.data);
await fs.promises.copyFile(cachedFileURL, finalFileURL);
} catch (e) {
warn(
buildOpts.logging,
'astro:assets',
`An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}`
);
await fs.promises.writeFile(finalFileURL, resultData.data);
}
} else {
await fs.promises.writeFile(finalFileURL, resultData.data);
}
return {
cached: false,
weight: {
before: Math.trunc(fileData.byteLength / 1024),
after: Math.trunc(resultData.data.byteLength / 1024),
},
};
}
export function getStaticImageList(): Iterable<
[string, { path: string; options: ImageTransform }]
> {
if (!globalThis?.astroAsset?.staticImages) {
return [];
}
return globalThis.astroAsset.staticImages?.entries();
}

View file

@ -1,11 +1,11 @@
import mime from 'mime/lite.js';
import type { APIRoute } from '../@types/astro.js';
import { isRemotePath } from '../core/path.js';
import { getConfiguredImageService } from './internal.js';
import { getConfiguredImageService, isRemoteAllowed } from './internal.js';
import { isLocalService } from './services/service.js';
import { etag } from './utils/etag.js';
// @ts-expect-error
import { imageServiceConfig } from 'astro:assets';
import { imageConfig } from 'astro:assets';
async function loadRemoteImage(src: URL) {
try {
@ -33,7 +33,7 @@ export const get: APIRoute = async ({ request }) => {
}
const url = new URL(request.url);
const transform = await imageService.parseURL(url, imageServiceConfig);
const transform = await imageService.parseURL(url, imageConfig);
if (!transform?.src) {
throw new Error('Incorrect transform returned by `parseURL`');
@ -45,17 +45,18 @@ export const get: APIRoute = async ({ request }) => {
const sourceUrl = isRemotePath(transform.src)
? new URL(transform.src)
: new URL(transform.src, url.origin);
if (isRemotePath(transform.src) && isRemoteAllowed(transform.src, imageConfig) === false) {
return new Response('Forbidden', { status: 403 });
}
inputBuffer = await loadRemoteImage(sourceUrl);
if (!inputBuffer) {
return new Response('Not Found', { status: 404 });
}
const { data, format } = await imageService.transform(
inputBuffer,
transform,
imageServiceConfig
);
const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig);
return new Response(data, {
status: 200,

View file

@ -1,4 +1,5 @@
import type { AstroSettings } from '../@types/astro.js';
import { isRemotePath } from '@astrojs/internal-helpers/path';
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { isLocalService, type ImageService } from './services/service.js';
import type {
@ -7,6 +8,7 @@ import type {
ImageTransform,
UnresolvedImageTransform,
} from './types.js';
import { matchHostname, matchPattern } from './utils/remotePattern.js';
export function injectImageEndpoint(settings: AstroSettings) {
settings.injectedRoutes.push({
@ -22,6 +24,26 @@ export function isESMImportedImage(src: ImageMetadata | string): src is ImageMet
return typeof src === 'object';
}
export function isRemoteImage(src: ImageMetadata | string): src is string {
return typeof src === 'string';
}
export function isRemoteAllowed(
src: string,
{
domains = [],
remotePatterns = [],
}: Partial<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>>
): boolean {
if (!isRemotePath(src)) return false;
const url = new URL(src);
return (
domains.some((domain) => matchHostname(url, domain)) ||
remotePatterns.some((remotePattern) => matchPattern(url, remotePattern))
);
}
export async function getConfiguredImageService(): Promise<ImageService> {
if (!globalThis?.astroAsset?.imageService) {
const { default: service }: { default: ImageService } = await import(
@ -43,7 +65,7 @@ export async function getConfiguredImageService(): Promise<ImageService> {
export async function getImage(
options: ImageTransform | UnresolvedImageTransform,
serviceConfig: Record<string, any>
imageConfig: AstroConfig['image']
): Promise<GetImageResult> {
if (!options || typeof options !== 'object') {
throw new AstroError({
@ -64,13 +86,18 @@ export async function getImage(
};
const validatedOptions = service.validateOptions
? await service.validateOptions(resolvedOptions, serviceConfig)
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;
let imageURL = await service.getURL(validatedOptions, serviceConfig);
let imageURL = await service.getURL(validatedOptions, imageConfig);
// In build and for local services, we need to collect the requested parameters so we can generate the final images
if (isLocalService(service) && globalThis.astroAsset.addStaticImage) {
if (
isLocalService(service) &&
globalThis.astroAsset.addStaticImage &&
// If `getURL` returned the same URL as the user provided, it means the service doesn't need to do anything
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
) {
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
}
@ -80,7 +107,7 @@ export async function getImage(
src: imageURL,
attributes:
service.getHTMLAttributes !== undefined
? service.getHTMLAttributes(validatedOptions, serviceConfig)
? service.getHTMLAttributes(validatedOptions, imageConfig)
: {},
};
}

View file

@ -1,7 +1,8 @@
import type { AstroConfig } from '../../@types/astro.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { joinPaths } from '../../core/path.js';
import { VALID_SUPPORTED_FORMATS } from '../consts.js';
import { isESMImportedImage } from '../internal.js';
import { isESMImportedImage, isRemoteAllowed } from '../internal.js';
import type { ImageOutputFormat, ImageTransform } from '../types.js';
export type ImageService = LocalImageService | ExternalImageService;
@ -23,7 +24,11 @@ export function parseQuality(quality: string): string | number {
return result;
}
interface SharedServiceProps {
type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
service: { entrypoint: string; config: T };
};
interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
/**
* Return the URL to the endpoint or URL your images are generated from.
*
@ -32,7 +37,7 @@ interface SharedServiceProps {
* For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image`
*
*/
getURL: (options: ImageTransform, serviceConfig: Record<string, any>) => string | Promise<string>;
getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
/**
* Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
*
@ -41,7 +46,7 @@ interface SharedServiceProps {
*/
getHTMLAttributes?: (
options: ImageTransform,
serviceConfig: Record<string, any>
imageConfig: ImageConfig<T>
) => Record<string, any> | Promise<Record<string, any>>;
/**
* Validate and return the options passed by the user.
@ -53,18 +58,20 @@ interface SharedServiceProps {
*/
validateOptions?: (
options: ImageTransform,
serviceConfig: Record<string, any>
imageConfig: ImageConfig<T>
) => ImageTransform | Promise<ImageTransform>;
}
export type ExternalImageService = SharedServiceProps;
export type ExternalImageService<T extends Record<string, any> = Record<string, any>> =
SharedServiceProps<T>;
export type LocalImageTransform = {
src: string;
[key: string]: any;
};
export interface LocalImageService extends SharedServiceProps {
export interface LocalImageService<T extends Record<string, any> = Record<string, any>>
extends SharedServiceProps<T> {
/**
* Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`.
*
@ -72,7 +79,7 @@ export interface LocalImageService extends SharedServiceProps {
*/
parseURL: (
url: URL,
serviceConfig: Record<string, any>
imageConfig: ImageConfig<T>
) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
/**
* Performs the image transformations on the input image and returns both the binary data and
@ -81,7 +88,7 @@ export interface LocalImageService extends SharedServiceProps {
transform: (
inputBuffer: Buffer,
transform: LocalImageTransform,
serviceConfig: Record<string, any>
imageConfig: ImageConfig<T>
) => Promise<{ data: Buffer; format: ImageOutputFormat }>;
}
@ -202,21 +209,31 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
decoding: attributes.decoding ?? 'async',
};
},
getURL(options: ImageTransform) {
// Both our currently available local services don't handle remote images, so we return the path as is.
if (!isESMImportedImage(options.src)) {
getURL(options, imageConfig) {
const searchParams = new URLSearchParams();
if (isESMImportedImage(options.src)) {
searchParams.append('href', options.src.src);
} else if (isRemoteAllowed(options.src, imageConfig)) {
searchParams.append('href', options.src);
} else {
// If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL
return options.src;
}
const searchParams = new URLSearchParams();
searchParams.append('href', options.src.src);
const params: Record<string, keyof typeof options> = {
w: 'width',
h: 'height',
q: 'quality',
f: 'format',
};
options.width && searchParams.append('w', options.width.toString());
options.height && searchParams.append('h', options.height.toString());
options.quality && searchParams.append('q', options.quality.toString());
options.format && searchParams.append('f', options.format);
Object.entries(params).forEach(([param, key]) => {
options[key] && searchParams.append(param, options[key].toString());
});
return joinPaths(import.meta.env.BASE_URL, '/_image?') + searchParams;
const imageEndpoint = joinPaths(import.meta.env.BASE_URL, '/_image');
return `${imageEndpoint}?${searchParams}`;
},
parseURL(url) {
const params = url.searchParams;

View file

@ -0,0 +1,63 @@
export type RemotePattern = {
hostname?: string;
pathname?: string;
protocol?: string;
port?: string;
};
export function matchPattern(url: URL, remotePattern: RemotePattern) {
return (
matchProtocol(url, remotePattern.protocol) &&
matchHostname(url, remotePattern.hostname, true) &&
matchPort(url, remotePattern.port) &&
matchPathname(url, remotePattern.pathname, true)
);
}
export function matchPort(url: URL, port?: string) {
return !port || port === url.port;
}
export function matchProtocol(url: URL, protocol?: string) {
return !protocol || protocol === url.protocol.slice(0, -1);
}
export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) {
if (!hostname) {
return true;
} else if (!allowWildcard || !hostname.startsWith('*')) {
return hostname === url.hostname;
} else if (hostname.startsWith('**.')) {
const slicedHostname = hostname.slice(2); // ** length
return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
} else if (hostname.startsWith('*.')) {
const slicedHostname = hostname.slice(1); // * length
const additionalSubdomains = url.hostname
.replace(slicedHostname, '')
.split('.')
.filter(Boolean);
return additionalSubdomains.length === 1;
}
return false;
}
export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) {
if (!pathname) {
return true;
} else if (!allowWildcard || !pathname.endsWith('*')) {
return pathname === url.pathname;
} else if (pathname.endsWith('/**')) {
const slicedPathname = pathname.slice(0, -2); // ** length
return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
} else if (pathname.endsWith('/*')) {
const slicedPathname = pathname.slice(0, -1); // * length
const additionalPathChunks = url.pathname
.replace(slicedPathname, '')
.split('/')
.filter(Boolean);
return additionalPathChunks.length === 1;
}
return false;
}

View file

@ -5,14 +5,13 @@ import { isESMImportedImage } from '../internal.js';
import type { ImageTransform } from '../types.js';
export function propsToFilename(transform: ImageTransform, hash: string) {
if (!isESMImportedImage(transform.src)) {
return transform.src;
}
let filename = removeQueryString(transform.src.src);
let filename = removeQueryString(
isESMImportedImage(transform.src) ? transform.src.src : transform.src
);
const ext = extname(filename);
filename = basename(filename, ext);
const outputExt = transform.format ? `.${transform.format}` : ext;
let outputExt = transform.format ? `.${transform.format}` : ext;
return `/${filename}_${hash}${outputExt}`;
}

View file

@ -12,7 +12,6 @@ import {
removeQueryString,
} from '../core/path.js';
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
import { isESMImportedImage } from './internal.js';
import { emitESMImage } from './utils/emitAsset.js';
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
@ -85,8 +84,8 @@ export default function assets({
import { getImage as getImageInternal } from "astro/assets";
export { default as Image } from "astro/components/Image.astro";
export const imageServiceConfig = ${JSON.stringify(settings.config.image.service.config)};
export const getImage = async (options) => await getImageInternal(options, imageServiceConfig);
export const imageConfig = ${JSON.stringify(settings.config.image)};
export const getImage = async (options) => await getImageInternal(options, imageConfig);
`;
}
},
@ -109,15 +108,10 @@ export default function assets({
if (globalThis.astroAsset.staticImages.has(hash)) {
filePath = globalThis.astroAsset.staticImages.get(hash)!.path;
} else {
// If the image is not imported, we can return the path as-is, since static references
// should only point ot valid paths for builds or remote images
if (!isESMImportedImage(options.src)) {
return options.src;
}
filePath = prependForwardSlash(
joinPaths(settings.config.build.assets, propsToFilename(options, hash))
);
globalThis.astroAsset.staticImages.set(hash, { path: filePath, options: options });
}

View file

@ -19,7 +19,7 @@ import type {
import {
generateImage as generateImageInternal,
getStaticImageList,
} from '../../assets/generate.js';
} from '../../assets/build/generate.js';
import {
eachPageDataFromEntryPoint,
eachRedirectPageData,

View file

@ -182,9 +182,35 @@ export const AstroConfigSchema = z.object({
]),
config: z.record(z.any()).default({}),
}),
domains: z.array(z.string()).default([]),
remotePatterns: z
.array(
z.object({
protocol: z.string().optional(),
hostname: z
.string()
.refine(
(val) => !val.includes('*') || val.startsWith('*.') || val.startsWith('**.'),
{
message: 'wildcards can only be placed at the beginning of the hostname',
}
)
.optional(),
port: z.string().optional(),
pathname: z
.string()
.refine((val) => !val.includes('*') || val.endsWith('/*') || val.endsWith('/**'), {
message: 'wildcards can only be placed at the end of a pathname',
})
.optional(),
})
)
.default([]),
})
.default({
service: { entrypoint: 'astro/assets/services/squoosh', config: {} },
domains: [],
remotePatterns: [],
}),
markdown: z
.object({

View file

@ -25,6 +25,7 @@ describe('astro:image', () => {
},
image: {
service: testImageService({ foo: 'bar' }),
domains: ['avatars.githubusercontent.com'],
},
});
@ -198,6 +199,15 @@ describe('astro:image', () => {
$ = cheerio.load(html);
});
it('has proper link and works', async () => {
let $img = $('#remote img');
let src = $img.attr('src');
expect(src.startsWith('/_image?')).to.be.true;
const imageRequest = await fixture.fetch(src);
expect(imageRequest.status).to.equal(200);
});
it('includes the provided alt', async () => {
let $img = $('#remote img');
expect($img.attr('alt')).to.equal('fred');
@ -587,6 +597,7 @@ describe('astro:image', () => {
},
image: {
service: testImageService(),
domains: ['astro.build'],
},
});
// Remove cache directory
@ -604,6 +615,15 @@ describe('astro:image', () => {
expect(data).to.be.an.instanceOf(Buffer);
});
it('writes out allowed remote images', async () => {
const html = await fixture.readFile('/remote/index.html');
const $ = cheerio.load(html);
const src = $('#remote img').attr('src');
expect(src.length).to.be.greaterThan(0);
const data = await fixture.readFile(src, null);
expect(data).to.be.an.instanceOf(Buffer);
});
it('writes out images to dist folder with proper extension if no format was passed', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
@ -708,12 +728,15 @@ describe('astro:image', () => {
});
it('has cache entries', async () => {
const generatedImages = (await fixture.glob('_astro/**/*.webp')).map((path) =>
basename(path)
);
const cachedImages = (await fixture.glob('../node_modules/.astro/assets/**/*.webp')).map(
(path) => basename(path)
);
const generatedImages = (await fixture.glob('_astro/**/*.webp'))
.map((path) => basename(path))
.sort();
const cachedImages = [
...(await fixture.glob('../node_modules/.astro/assets/**/*.webp')),
...(await fixture.glob('../node_modules/.astro/assets/**/*.json')),
]
.map((path) => basename(path).replace('.webp.json', '.webp'))
.sort();
expect(generatedImages).to.deep.equal(cachedImages);
});

View file

@ -0,0 +1,7 @@
---
import { Image } from "astro:assets";
---
<div id="remote">
<Image src="https://astro.build/sponsors.png" alt="fred" width="48" height="48" />
</div>

View file

@ -17,8 +17,8 @@ export default {
...baseService,
getHTMLAttributes(options, serviceConfig) {
options['data-service'] = 'my-custom-service';
if (serviceConfig.foo) {
options['data-service-config'] = serviceConfig.foo;
if (serviceConfig.service.config.foo) {
options['data-service-config'] = serviceConfig.service.config.foo;
}
return baseService.getHTMLAttributes(options);
},

View file

@ -0,0 +1,111 @@
import { expect } from 'chai';
import {
matchProtocol,
matchPort,
matchHostname,
matchPathname,
matchPattern,
} from '../../../dist/assets/utils/remotePattern.js';
describe('astro/src/assets/utils/remotePattern', () => {
const url1 = new URL('https://docs.astro.build/en/getting-started');
const url2 = new URL('http://preview.docs.astro.build:8080/');
const url3 = new URL('https://astro.build/');
const url4 = new URL('https://example.co/');
describe('remote pattern matchers', () => {
it('matches protocol', async () => {
// undefined
expect(matchProtocol(url1)).to.be.true;
// defined, true/false
expect(matchProtocol(url1, 'http')).to.be.false;
expect(matchProtocol(url1, 'https')).to.be.true;
});
it('matches port', async () => {
// undefined
expect(matchPort(url1)).to.be.true;
// defined, but port is empty (default port used in URL)
expect(matchPort(url1, '')).to.be.true;
// defined and port is custom
expect(matchPort(url2, '8080')).to.be.true;
});
it('matches hostname (no wildcards)', async () => {
// undefined
expect(matchHostname(url1)).to.be.true;
// defined, true/false
expect(matchHostname(url1, 'astro.build')).to.be.false;
expect(matchHostname(url1, 'docs.astro.build')).to.be.true;
});
it('matches hostname (with wildcards)', async () => {
// defined, true/false
expect(matchHostname(url1, 'docs.astro.build', true)).to.be.true;
expect(matchHostname(url1, '**.astro.build', true)).to.be.true;
expect(matchHostname(url1, '*.astro.build', true)).to.be.true;
expect(matchHostname(url2, '*.astro.build', true)).to.be.false;
expect(matchHostname(url2, '**.astro.build', true)).to.be.true;
expect(matchHostname(url3, 'astro.build', true)).to.be.true;
expect(matchHostname(url3, '*.astro.build', true)).to.be.false;
expect(matchHostname(url3, '**.astro.build', true)).to.be.false;
});
it('matches pathname (no wildcards)', async () => {
// undefined
expect(matchPathname(url1)).to.be.true;
// defined, true/false
expect(matchPathname(url1, '/')).to.be.false;
expect(matchPathname(url1, '/en/getting-started')).to.be.true;
});
it('matches pathname (with wildcards)', async () => {
// defined, true/false
expect(matchPathname(url1, '/en/**', true)).to.be.true;
expect(matchPathname(url1, '/en/*', true)).to.be.true;
expect(matchPathname(url1, '/**', true)).to.be.true;
expect(matchPathname(url2, '/**', true)).to.be.false;
expect(matchPathname(url2, '/*', true)).to.be.false;
});
it('matches patterns', async () => {
expect(matchPattern(url1, {})).to.be.true;
expect(
matchPattern(url1, {
protocol: 'https',
})
).to.be.true;
expect(
matchPattern(url1, {
protocol: 'https',
hostname: '**.astro.build',
})
).to.be.true;
expect(
matchPattern(url1, {
protocol: 'https',
hostname: '**.astro.build',
pathname: '/en/**',
})
).to.be.true;
expect(
matchPattern(url4, {
protocol: 'https',
hostname: 'example.com',
})
).to.be.false;
});
});
});

View file

@ -3,7 +3,7 @@ import { isESMImportedImage, sharedValidateOptions } from './shared';
const service: ExternalImageService = {
validateOptions: (options, serviceOptions) =>
sharedValidateOptions(options, serviceOptions, 'production'),
sharedValidateOptions(options, serviceOptions.service.config, 'production'),
getHTMLAttributes(options) {
const { inputtedWidth, ...props } = options;

View file

@ -5,7 +5,7 @@ import { sharedValidateOptions } from './shared';
const service: LocalImageService = {
validateOptions: (options, serviceOptions) =>
sharedValidateOptions(options, serviceOptions, 'development'),
sharedValidateOptions(options, serviceOptions.service.config, 'development'),
getHTMLAttributes(options, serviceOptions) {
const { inputtedWidth, ...props } = options;

View file

@ -89,10 +89,10 @@ export function getImageConfig(
export function sharedValidateOptions(
options: ImageTransform,
serviceOptions: Record<string, any>,
serviceConfig: Record<string, any>,
mode: 'development' | 'production'
) {
const vercelImageOptions = serviceOptions as VercelImageConfig;
const vercelImageOptions = serviceConfig as VercelImageConfig;
if (
mode === 'development' &&

View file

@ -578,6 +578,9 @@ importers:
html-escaper:
specifier: ^3.0.3
version: 3.0.3
http-cache-semantics:
specifier: ^4.1.1
version: 4.1.1
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@ -690,6 +693,9 @@ importers:
'@types/html-escaper':
specifier: ^3.0.0
version: 3.0.0
'@types/http-cache-semantics':
specifier: ^4.0.1
version: 4.0.1
'@types/js-yaml':
specifier: ^4.0.5
version: 4.0.5