diff --git a/.changeset/heavy-cats-own.md b/.changeset/heavy-cats-own.md new file mode 100644 index 000000000000..1b9ca4fa542c --- /dev/null +++ b/.changeset/heavy-cats-own.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes images not working in development when using setups with port forwarding diff --git a/packages/astro/src/assets/endpoint/dev.ts b/packages/astro/src/assets/endpoint/dev.ts index 075215bf7bf9..1c0a599349ed 100644 --- a/packages/astro/src/assets/endpoint/dev.ts +++ b/packages/astro/src/assets/endpoint/dev.ts @@ -1,44 +1,69 @@ // @ts-expect-error -import { root } from 'astro:config/server'; +import { viteFSConfig, safeModulePaths } from 'astro:assets'; import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { isParentDirectory } from '@astrojs/internal-helpers/path'; import type { APIRoute } from '../../types/public/common.js'; import { handleImageRequest, loadRemoteImage } from './shared.js'; +import os from 'node:os'; +import { isFileLoadingAllowed, type AnymatchFn, type ResolvedConfig } from 'vite'; +import picomatch from 'picomatch'; + +function replaceFileSystemReferences(src: string) { + return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, ''); +} async function loadLocalImage(src: string, url: URL) { - // Vite uses /@fs/ to denote filesystem access, we can fetch those files directly + let returnValue: Buffer | undefined; + let fsPath: string | undefined; + + // Vite uses /@fs/ to denote filesystem access, but we need to convert that to a real path to load it if (src.startsWith('/@fs/')) { - try { - const res = await fetch(new URL(src, url)); - - if (!res.ok) { - return undefined; - } - - return Buffer.from(await res.arrayBuffer()); - } catch { - return undefined; - } + fsPath = replaceFileSystemReferences(src); } - // Vite allows loading files directly from the filesystem - // as long as they are inside the project root. - if (isParentDirectory(fileURLToPath(root), src)) { + // Vite only uses the fs config, but the types ask for the full config + // fsDenyGlob's implementation is internal from https://github.com/vitejs/vite/blob/e6156f71f0e21f4068941b63bcc17b0e9b0a7455/packages/vite/src/node/config.ts#L1931 + if (fsPath && isFileLoadingAllowed({ fsDenyGlob: picomatch( + // matchBase: true does not work as it's documented + // https://github.com/micromatch/picomatch/issues/89 + // convert patterns without `/` on our side for now + viteFSConfig.deny.map((pattern: string) => + pattern.includes('/') ? pattern : `**/${pattern}`, + ), + { + matchBase: false, + nocase: true, + dot: true, + }, + ), server: { fs: viteFSConfig }, safeModulePaths } as ResolvedConfig & { fsDenyGlob: AnymatchFn, safeModulePaths: Set }, fsPath)) { try { - return await readFile(src); + returnValue = await readFile(fsPath); } catch { - return undefined; + returnValue = undefined; + } + + // If we couldn't load it directly, try loading it through Vite as a fallback, which will also respect Vite's fs rules + if (!returnValue) { + try { + const res = await fetch(new URL(src, url)); + + if (res.ok) { + returnValue = Buffer.from(await res.arrayBuffer()); + } + } catch { + returnValue = undefined; + } } } else { // Otherwise we'll assume it's a local URL and try to load it via fetch const sourceUrl = new URL(src, url.origin); // This is only allowed if this is the same origin if (sourceUrl.origin !== url.origin) { - return undefined; + returnValue = undefined; } return loadRemoteImage(sourceUrl); } + + return returnValue; } /** diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 9b94dde33a6e..e79dd61e704e 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -151,6 +151,15 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl export { default as Font } from "astro/components/Font.astro"; import * as fontsMod from 'virtual:astro:assets/fonts/internal'; import { createGetFontData } from "astro/assets/fonts/runtime"; + + export const viteFSConfig = ${JSON.stringify( + resolvedConfig.server.fs + )} ?? {}; + + export const safeModulePaths = new Set(${JSON.stringify( + // @ts-expect-error safeModulePaths is internal to Vite + Array.from(resolvedConfig.safeModulePaths), + )} ?? []); const assetQueryParams = ${ settings.adapter?.client?.assetQueryParams diff --git a/packages/astro/test/core-image-fs-config.test.js b/packages/astro/test/core-image-fs-config.test.js new file mode 100644 index 000000000000..0d3d2e4ca4e3 --- /dev/null +++ b/packages/astro/test/core-image-fs-config.test.js @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +describe('Image optimization with Vite fs config', () => { + describe('fs.allow and fs.deny', () => { + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-fs-config/', + vite: { + server: { + fs: { + allow: [ + fileURLToPath(new URL('./fixtures/core-image-fs-config/outside', import.meta.url)), + ], + deny: [ + fileURLToPath(new URL('./fixtures/core-image-fs-config-outside-denied', import.meta.url)), + ], + }, + }, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('allows loading images from directories in fs.allow via /_image endpoint', async () => { + // Get the absolute path to the allowed image + const outsidePath = fileURLToPath( + new URL('./fixtures/core-image-fs-config/outside/penguin-outside.jpg', import.meta.url) + ); + const fsUrl = '/@fs/' + outsidePath.replace(/\\/g, '/'); + + // Try to load the image via the /_image endpoint + const imageUrl = `/_image?href=${encodeURIComponent(fsUrl)}&f=webp&w=300&h=200`; + const response = await fixture.fetch(imageUrl); + + assert.equal(response.status, 200, 'Should successfully load image from fs.allow directory'); + assert.equal(response.headers.get('content-type'), 'image/webp', 'Should return webp format'); + }); + + it('denies loading images from directories in fs.deny via /_image endpoint', async () => { + // Get the absolute path to the denied image + const deniedPath = fileURLToPath( + new URL('./fixtures/core-image-fs-config-outside-denied/penguin-denied.jpg', import.meta.url) + ); + const fsUrl = '/@fs/' + deniedPath.replace(/\\/g, '/'); + + // Try to load the image via the /_image endpoint + const imageUrl = `/_image?href=${encodeURIComponent(fsUrl)}&f=webp&w=300&h=200`; + const response = await fixture.fetch(imageUrl); + + // Should fail because the directory is in fs.deny + assert.notEqual(response.status, 200, 'Should deny access to image in fs.deny directory'); + }); + + it('denies loading images from inside the project that are not in allow list', async () => { + // Get the absolute path to an image that is IN the project but not in allow list + // and not being imported (so not in safeModulePaths for this test) + const projectPath = fileURLToPath( + new URL('./fixtures/core-image-fs-config/src/sibling/penguin-sibling.jpg', import.meta.url) + ); + const fsUrl = '/@fs/' + projectPath.replace(/\\/g, '/'); + + // Try to load the image via the /_image endpoint + const imageUrl = `/_image?href=${encodeURIComponent(fsUrl)}&f=webp&w=300&h=200`; + const response = await fixture.fetch(imageUrl); + + // Should fail because it's not in the allow list (only 'outside' directory is allowed) + // and it's not in safeModulePaths (not being imported in this test context) + assert.notEqual(response.status, 200, 'Should deny access to project file not in allow list'); + }); + }); + + describe('safeModulePaths', () => { + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-fs-config/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('allows imported images from sibling directory that are not in allow or deny (safeModulePaths)', async () => { + // Even if a file is not technically allowed (ex: it's outside the project's folder), if a file is directly imported it'll be allowed to be loaded through /@fs urls + const response = await fixture.fetch('/imported'); + assert.equal(response.status, 200, 'Page with imported sibling image should render'); + + const html = await response.text(); + const $ = cheerio.load(html); + + const img = $('#sibling-image'); + assert.ok(img.length > 0, 'Image element should be present'); + + const imgSrc = img.attr('src'); + assert.ok(imgSrc, 'Should have image src'); + assert.ok(imgSrc.includes('/_image'), 'Should use image optimization endpoint'); + + // Try to fetch the optimized image + const imgResponse = await fixture.fetch(imgSrc); + assert.equal(imgResponse.status, 200, 'Optimized sibling image should be accessible'); + }); + }); +}); diff --git a/packages/astro/test/fixtures/core-image-fs-config-outside-denied/penguin-denied.jpg b/packages/astro/test/fixtures/core-image-fs-config-outside-denied/penguin-denied.jpg new file mode 100644 index 000000000000..e859ac3c992f Binary files /dev/null and b/packages/astro/test/fixtures/core-image-fs-config-outside-denied/penguin-denied.jpg differ diff --git a/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg b/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg new file mode 100644 index 000000000000..e859ac3c992f Binary files /dev/null and b/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg differ diff --git a/packages/astro/test/fixtures/core-image-fs-config/astro.config.mjs b/packages/astro/test/fixtures/core-image-fs-config/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-fs-config/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/core-image-fs-config/outside/penguin-outside.jpg b/packages/astro/test/fixtures/core-image-fs-config/outside/penguin-outside.jpg new file mode 100644 index 000000000000..1a8986ac5092 Binary files /dev/null and b/packages/astro/test/fixtures/core-image-fs-config/outside/penguin-outside.jpg differ diff --git a/packages/astro/test/fixtures/core-image-fs-config/package.json b/packages/astro/test/fixtures/core-image-fs-config/package.json new file mode 100644 index 000000000000..329d0e068dea --- /dev/null +++ b/packages/astro/test/fixtures/core-image-fs-config/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/core-image-fs-config", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/core-image-fs-config/src/pages/imported.astro b/packages/astro/test/fixtures/core-image-fs-config/src/pages/imported.astro new file mode 100644 index 000000000000..32f1d6062a21 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-fs-config/src/pages/imported.astro @@ -0,0 +1,14 @@ +--- +import { Image } from 'astro:assets'; +import siblingImage from '../sibling/penguin-sibling.jpg'; +--- + + + + Imported Image + + +

Imported Sibling Image

+ Penguin from sibling directory + + diff --git a/packages/astro/test/fixtures/core-image-fs-config/src/pages/index.astro b/packages/astro/test/fixtures/core-image-fs-config/src/pages/index.astro new file mode 100644 index 000000000000..050633896193 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-fs-config/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +--- + + + + FS Config Test + + +

FS Config Test Fixture

+

This fixture tests Vite's fs.allow and fs.deny configuration.

+ + diff --git a/packages/astro/test/fixtures/core-image-fs-config/src/sibling/penguin-sibling.jpg b/packages/astro/test/fixtures/core-image-fs-config/src/sibling/penguin-sibling.jpg new file mode 100644 index 000000000000..1a8986ac5092 Binary files /dev/null and b/packages/astro/test/fixtures/core-image-fs-config/src/sibling/penguin-sibling.jpg differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 909ab9be4854..67b5842a2174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2836,6 +2836,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-fs-config: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-infersize: dependencies: astro: