Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heavy-cats-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes images not working in development when using setups with port forwarding
67 changes: 46 additions & 21 deletions packages/astro/src/assets/endpoint/dev.ts
Original file line number Diff line number Diff line change
@@ -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<string> }, 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;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions packages/astro/test/core-image-fs-config.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/core-image-fs-config",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
import { Image } from 'astro:assets';
import siblingImage from '../sibling/penguin-sibling.jpg';
---

<html>
<head>
<title>Imported Image</title>
</head>
<body>
<h1>Imported Sibling Image</h1>
<Image id="sibling-image" src={siblingImage} width={300} height={200} format="webp" alt="Penguin from sibling directory" />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
---

<html>
<head>
<title>FS Config Test</title>
</head>
<body>
<h1>FS Config Test Fixture</h1>
<p>This fixture tests Vite's fs.allow and fs.deny configuration.</p>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading