Skip to content
Open
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
67 changes: 48 additions & 19 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

const path = require('node:path')
const { fileURLToPath } = require('node:url')
const { statSync } = require('node:fs')
const { glob } = require('glob')
const { statSync, lstatSync, realpathSync } = require('node:fs')
const { glob } = require('node:fs/promises')
const fp = require('fastify-plugin')
const send = require('@fastify/send')
const encodingNegotiator = require('@fastify/accept-negotiator')
Expand Down Expand Up @@ -63,7 +63,6 @@ async function fastifyStatic (fastify, opts) {
: prefix + '/'
}

// Set the schema hide property if defined in opts or true by default
const routeOpts = {
constraints: opts.constraints,
schema: {
Expand Down Expand Up @@ -139,25 +138,55 @@ async function fastifyStatic (fastify, opts) {
for (let rootPath of roots) {
rootPath = rootPath.split(path.win32.sep).join(path.posix.sep)
!rootPath.endsWith('/') && (rootPath += '/')
const files = await glob('**/**', {
cwd: rootPath, absolute: false, follow: true, nodir: true, dot: opts.serveDotFiles, ignore: opts.globIgnore
})

for (let file of files) {
file = file.split(path.win32.sep).join(path.posix.sep)
const route = prefix + file

if (routes.has(route)) {
continue
}

routes.add(route)
const globPattern = opts.serveDotFiles ? '{**/**,.**/**}' : '**/**'
const globExclude = opts.globIgnore?.length
? (f) => opts.globIgnore.some(p => path.matchesGlob(f, p))
: undefined

const scanQueue = [{ cwd: rootPath.slice(0, -1), relPrefix: '' }]
const visitedDirs = new Set([realpathSync(rootPath.slice(0, -1))])

const toUnixPath = (p) => p.split(path.win32.sep).join(path.posix.sep)
const tryFsSync = (fn, p) => { try { return fn(p) } catch { return null } }

while (scanQueue.length > 0) {
const { cwd: scanCwd, relPrefix } = scanQueue.shift()
for await (const f of glob(globPattern, { cwd: scanCwd, exclude: globExclude })) {
if (f === '.') continue

const file = toUnixPath(f)
const fullPath = path.join(scanCwd, file)
const lstat = tryFsSync(lstatSync, fullPath)
if (!lstat || lstat.isDirectory()) continue

const relFile = relPrefix ? `${relPrefix}/${file}` : file

if (lstat.isSymbolicLink()) {
const stat = tryFsSync(statSync, fullPath)
if (!stat) continue

if (stat.isDirectory()) {
const realPath = tryFsSync(realpathSync, fullPath)
/* c8 ignore next */
if (!realPath) continue
if (!visitedDirs.has(realPath)) {
visitedDirs.add(realPath)
scanQueue.push({ cwd: fullPath, relPrefix: relFile })
}
continue
}
}

setUpHeadAndGet(routeOpts, route, `/${file}`, rootPath)
const route = prefix + relFile
if (routes.has(route)) continue
routes.add(route)
setUpHeadAndGet(routeOpts, route, `/${relFile}`, rootPath)

const key = path.posix.basename(route)
if (indexes.has(key) && !indexDirs.has(key)) {
indexDirs.set(path.posix.dirname(route), rootPath)
const key = path.posix.basename(route)
if (indexes.has(key) && !indexDirs.has(key)) {
indexDirs.set(path.posix.dirname(route), rootPath)
}
}
}
}
Expand Down
15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,22 @@
}
],
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"@fastify/accept-negotiator": "^2.0.1",
"@fastify/send": "^4.1.0",
"content-disposition": "^1.0.1",
"fastify-plugin": "^5.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
"fastify-plugin": "^5.1.0",
"fastq": "^1.17.1"
},
"devDependencies": {
"@fastify/compress": "^8.0.0",
"@fastify/compress": "^8.3.1",
"@types/node": "^25.0.3",
"borp": "^1.0.0",
"c8": "^11.0.0",
"concat-stream": "^2.0.0",
"eslint": "^9.17.0",
"fastify": "^5.1.0",
"fastify": "^5.8.4",
"neostandard": "^0.12.0",
"pino": "^10.0.0",
"pino": "^10.3.1",
"proxyquire": "^2.1.3",
"tsd": "^0.33.0"
},
Expand Down
118 changes: 114 additions & 4 deletions test/static.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,7 @@
})
})

test('fastify with exposeHeadRoutes', async t => {

Check failure on line 1658 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

fastify with exposeHeadRoutes

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(1)

const pluginOptions = {
Expand All @@ -1682,7 +1682,7 @@
})
})

test('register with wildcard false', async t => {

Check failure on line 1685 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register with wildcard false

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(8)

const pluginOptions = {
Expand Down Expand Up @@ -1780,7 +1780,7 @@
})
})

test('register with wildcard false (trailing slash in the root)', async t => {

Check failure on line 1783 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register with wildcard false (trailing slash in the root)

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(5)

const pluginOptions = {
Expand Down Expand Up @@ -1890,7 +1890,7 @@
await t.assert.rejects(fastify.listen({ port: 0 }))
})

test('register with wildcard false and alternative index', async t => {

Check failure on line 1893 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register with wildcard false and alternative index

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(10)

const pluginOptions = {
Expand Down Expand Up @@ -2013,7 +2013,7 @@
})
})

test('register /static with wildcard false and alternative index', async t => {

Check failure on line 2016 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register /static with wildcard false and alternative index

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(10)

const pluginOptions = {
Expand Down Expand Up @@ -2265,7 +2265,7 @@
})
})

test('register /static with redirect true and wildcard false', async t => {

Check failure on line 2268 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register /static with redirect true and wildcard false

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(7)

const pluginOptions = {
Expand Down Expand Up @@ -2517,10 +2517,8 @@

test('register with failing glob handler', async (t) => {
const fastifyStatic = proxyquire.noCallThru()('../', {
glob: function globStub (_pattern, _options, cb) {
process.nextTick(function () {
return cb(new Error('mock glob error'))
})
'node:fs/promises': {
glob: async function * globStub () { throw new Error('mock glob error') }
}
})

Expand Down Expand Up @@ -2662,7 +2660,7 @@
)
})

test('register /static and /static2 without wildcard', async t => {

Check failure on line 2663 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

register /static and /static2 without wildcard

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
t.plan(2)

const pluginOptions = {
Expand Down Expand Up @@ -2845,7 +2843,7 @@
}
)

test(

Check failure on line 2846 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

will serve pre-compressed files with .br at the highest priority (with wildcard: false)

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
'will serve pre-compressed files with .br at the highest priority (with wildcard: false)',
async (t) => {
const pluginOptions = {
Expand Down Expand Up @@ -2875,7 +2873,7 @@
}
)

test(

Check failure on line 2876 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

will serve pre-compressed files and fallback to .gz if .br is not on disk (with wildcard: false)

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
'will serve pre-compressed files and fallback to .gz if .br is not on disk (with wildcard: false)',
async (t) => {
const pluginOptions = {
Expand Down Expand Up @@ -2905,7 +2903,7 @@
}
)

test(

Check failure on line 2906 in test/static.test.js

View workflow job for this annotation

GitHub Actions / test / Test (20, ubuntu-latest)

will serve pre-compressed files with .gzip if * directive used (with wildcard: false)

TypeError [Error]: glob(...) is not a function or its return value is not async iterable at fastifyStatic (/home/runner/work/fastify-static/fastify-static/index.js:155:33) at Plugin.exec (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/lib/plugin.js:125:28) at Boot._loadPlugin (/home/runner/work/fastify-static/fastify-static/node_modules/avvio/boot.js:446:10) at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
'will serve pre-compressed files with .gzip if * directive used (with wildcard: false)',
async (t) => {
const pluginOptions = {
Expand Down Expand Up @@ -3353,6 +3351,43 @@
t.assert.deepStrictEqual(response2.status, 200)
})

test('should not infinite-loop on circular symlinks with wildcard false', async (t) => {
const base = path.join(__dirname, '/static-symbolic-link')
const dirA = path.join(base, 'circular-a')
const linkB = path.join(base, 'circular-b')
// clean up any leftovers from a previous failed run
try { fs.unlinkSync(path.join(dirA, 'link-to-b')) } catch { /* not there */ }
try { fs.unlinkSync(linkB) } catch { /* not there */ }
fs.rmSync(dirA, { recursive: true, force: true })
fs.mkdirSync(dirA)
fs.symlinkSync(path.join(dirA, 'link-to-b'), linkB, 'dir') // circular-b → circular-a/link-to-b
fs.symlinkSync(linkB, path.join(dirA, 'link-to-b'), 'dir') // circular-a/link-to-b → circular-b

t.after(() => {
try { fs.unlinkSync(path.join(dirA, 'link-to-b')) } catch { /* already gone */ }
try { fs.unlinkSync(linkB) } catch { /* already gone */ }
try { fs.rmdirSync(dirA) } catch { /* already gone */ }
})

const fastify = Fastify()
fastify.register(fastifyStatic, {
root: base,
wildcard: false
})
t.after(() => fastify.close())

// fastify.listen must complete (not hang/crash) — that is the assertion
await fastify.listen({ port: 0 })
fastify.server.unref()

// Original non-circular files still served correctly
const response = await fetch(
'http://localhost:' + fastify.server.address().port + '/origin/subdir/subdir/index.html'
)
t.assert.ok(response.ok)
t.assert.deepStrictEqual(response.status, 200)
})

test('should serve files into hidden dir with wildcard `false`', async (t) => {
t.plan(8)

Expand Down Expand Up @@ -3871,3 +3906,78 @@
await response.text()
})
})

test('should serve file from a symlinked directory', async (t) => {
t.plan(1)

const tempDir = fs.mkdtempSync(path.join(__dirname, 'symlink-test-'))
const realDir = path.join(tempDir, 'real')
const symlinkedDir = path.join(tempDir, 'symlinked')
const testFilePath = path.join(realDir, 'test.txt')
const fileContent = 'symlink test file'

fs.mkdirSync(realDir)
fs.writeFileSync(testFilePath, fileContent)
fs.symlinkSync(realDir, symlinkedDir, 'dir')

const fastify = Fastify()
fastify.register(fastifyStatic, {
root: tempDir,
prefix: '/',
followSymlinks: true
})

t.after(async () => {
await fastify.close()
fs.rmSync(tempDir, { recursive: true, force: true })
})

await fastify.listen({ port: 0 })
fastify.server.unref()

await t.test('request file via symlink', async (t) => {
t.plan(3)
const response = await fetch(`http://localhost:${fastify.server.address().port}/symlinked/test.txt`)
t.assert.ok(response.ok)
t.assert.deepStrictEqual(response.status, 200)
t.assert.deepStrictEqual(await response.text(), fileContent)
})
})

test('should serve file with user-defined wildcard route', async (t) => {
t.plan(1)

const tempDir = fs.mkdtempSync(path.join(__dirname, 'wildcard-sendFile-test-'))
const testFilePath = path.join(tempDir, 'foo', 'bar.txt')
const fileContent = 'wildcard sendFile test'

fs.mkdirSync(path.dirname(testFilePath), { recursive: true })
fs.writeFileSync(testFilePath, fileContent)

const fastify = Fastify()
// The root needs to be specified for reply.sendFile to resolve relative paths
fastify.register(fastifyStatic, {
root: tempDir,
serve: false // We don't want the plugin to serve files, just decorate reply.sendFile
})

fastify.get('/files/*', (req, reply) => {
reply.sendFile(req.params['*'])
})

t.after(async () => {
await fastify.close()
fs.rmSync(tempDir, { recursive: true, force: true })
})

await fastify.listen({ port: 0 })
fastify.server.unref()

await t.test('request file via wildcard route', async (t) => {
t.plan(3)
const response = await fetch(`http://localhost:${fastify.server.address().port}/files/foo/bar.txt`)
t.assert.ok(response.ok)
t.assert.deepStrictEqual(response.status, 200)
t.assert.deepStrictEqual(await response.text(), fileContent)
})
})
Loading