diff --git a/index.js b/index.js index 415f0d8..1e372dc 100644 --- a/index.js +++ b/index.js @@ -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') @@ -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: { @@ -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) + } } } } diff --git a/package.json b/package.json index 6f2e023..d7a1b7e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/test/static.test.js b/test/static.test.js index ffdc4cc..1e6a8ca 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -2517,10 +2517,8 @@ test('if dotfiles are properly served according to plugin options', async (t) => 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') } } }) @@ -3353,6 +3351,43 @@ test('should follow symbolic link without wildcard', async (t) => { 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) @@ -3871,3 +3906,78 @@ test('register with wildcard false and globIgnore', async t => { 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) + }) +})