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
68 changes: 68 additions & 0 deletions codemods/sendfile-options/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Migrate `res.sendFile` options

Express 5 changes several `res.sendFile` options, mirroring the `express.static`
changes:

- The `dotfiles` option now also applies to hidden **directories** in the path, not
just hidden files. In Express 4 a hidden directory in the path was served by
default; Express 5 returns a **404 Not Found** unless you opt in with
`dotfiles: 'allow'`.
- The `hidden` option is removed and replaced by `dotfiles`.
- The `from` option (an undocumented alias for `root`) is removed and replaced by `root`.

This codemod updates `res.sendFile()` calls to preserve the Express 4 behavior:

1. Adds an explicit `dotfiles: 'allow'` option to calls that don't already specify a `dotfiles` (or `hidden`) option.
2. Renames `hidden` to `dotfiles` (`hidden: true` → `dotfiles: 'allow'`, `hidden: false` → `dotfiles: 'ignore'`).
3. Renames `from` to `root`.

Only calls whose receiver is a response object (a route/middleware handler
parameter, e.g. the `res` in `(req, res) => res.sendFile(...)`) are rewritten.

## Example

```diff
app.get('/build', (req, res) => {
- res.sendFile('/var/www/app/.cache/index.html')
+ res.sendFile('/var/www/app/.cache/index.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })
})
```

### With existing options

```diff
- res.sendFile('index.html', { maxAge: '1d' })
+ res.sendFile('index.html', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })
```

### Removed `hidden` / `from` options

```diff
- res.sendFile(req.params.name, { hidden: true, from: '/uploads' })
+ res.sendFile(req.params.name, { dotfiles: 'allow', root: '/uploads' })
```

### With a trailing callback

The options object is inserted before the callback:

```diff
- res.sendFile('index.html', (err) => next(err))
+ res.sendFile('index.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }, (err) => next(err))
```

## Security Consideration

After running this codemod, review each `res.sendFile()` call to determine if
serving dotfiles is actually necessary for your application. If you don't need to
serve dotfiles, you can:

1. Remove the `dotfiles: 'allow'` option to use the new Express 5 default (`"ignore"`)
2. Or explicitly set `dotfiles: 'deny'` to return a 403 Forbidden for dotfile requests

Note that passing a `root` option scopes the `dotfiles` check to the part of the
path relative to `root`, so a hidden directory in `root` itself is unaffected.

## References

- [Express 5 Migration Guide - res.sendFile() options](https://expressjs.com/en/guide/migrating-5#ressendfile-options)
26 changes: 26 additions & 0 deletions codemods/sendfile-options/codemod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
schema_version: "1.0"
name: "@expressjs/sendfile-options"
version: "1.0.0"
description: Migrates res.sendFile() options to Express 5 - adds an explicit dotfiles option and renames the removed hidden and from options
author: Sebastian Beltran <bjohansebas@gmail.com>
license: MIT
workflow: workflow.yaml
repository: "https://github.com/expressjs/codemod/tree/HEAD/codemods/sendfile-options"
category: migration

targets:
languages:
- javascript
- typescript

keywords:
- transformation
- migration
- express
- sendFile
- dotfiles
- res.sendFile

registry:
access: public
visibility: public
22 changes: 22 additions & 0 deletions codemods/sendfile-options/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@expressjs/sendfile-options",
"private": true,
"version": "1.0.0",
"description": "Migrates res.sendFile() options to Express 5: adds an explicit dotfiles option and renames the removed hidden and from options",
"type": "module",
"scripts": {
"test": "npx codemod jssg test -l typescript ./src/workflow.ts ./"
},
"repository": {
"type": "git",
"url": "git+https://github.com/expressjs/codemod.git",
"directory": "codemods/sendfile-options",
"bugs": "https://github.com/expressjs/codemod/issues"
},
"author": "Sebastian Beltran <bjohansebas@gmail.com>",
"license": "MIT",
"homepage": "https://github.com/expressjs/codemod/blob/main/codemods/sendfile-options/README.md",
"devDependencies": {
"@codemod.com/jssg-types": "^1.5.0"
}
}
197 changes: 197 additions & 0 deletions codemods/sendfile-options/src/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type Js from '@codemod.com/jssg-types/src/langs/javascript'
import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/src/main'

const DOTFILES_OPTION = "dotfiles: 'allow' /* Express 5: preserve v4 behavior */"

// Migrates `res.sendFile()` options to Express 5: adds an explicit `dotfiles`
// option to preserve the Express 4 behavior (v4 served hidden directories in the
// path by default, v5 returns 404), and renames the removed `hidden` and `from`
// options to `dotfiles` and `root`.
//
// `res.sendFile(path[, options][, callback])` makes the argument handling more
// involved than `express.static()`: when no options object is present, one is
// inserted before any trailing callback.
async function transform(root: SgRoot<Js>): Promise<string | null> {
const rootNode = root.root()
const edits: Edit[] = []

const calls = rootNode.findAll({
rule: {
any: [
{ pattern: '$RES.sendFile($PATH)' },
{ pattern: '$RES.sendFile($PATH, $SECOND)' },
{ pattern: '$RES.sendFile($PATH, $SECOND, $THIRD)' },
],
},
})

if (!calls.length) return null

for (const call of calls) {
const target = call.getMatch('RES')
const pathArg = call.getMatch('PATH')
if (!target || !pathArg) continue

// Only rewrite calls on a response object, i.e. a function parameter such as
// the `res` in `(req, res) => res.sendFile(...)`. This avoids touching
// unrelated `.sendFile()` calls on objects we can't identify as Express.
if (!isResponseBinding(target)) continue

const second = call.getMatch('SECOND')

if (!second) {
// `res.sendFile(path)` -> add an options object.
edits.push(call.replace(`${target.text()}.sendFile(${pathArg.text()}, { ${DOTFILES_OPTION} })`))
continue
}

if (second.is('object')) {
// `res.sendFile(path, { ... }[, cb])` -> rewrite the options object.
const result = transformOptions(second)
if (!result) continue

let newOpts = result.text
if (!result.hasDotfiles) {
newOpts = addDotfilesOption(newOpts)
}

const originalOpts = second.text()
if (newOpts === originalOpts) continue

edits.push(call.replace(call.text().replace(originalOpts, newOpts)))
continue
}

// `res.sendFile(path, callback)` -> insert the options object before the
// callback. Anything else as the second argument (e.g. a variable we can't
// see into) is left untouched.
const third = call.getMatch('THIRD')
if (!third && isCallback(second)) {
edits.push(call.replace(`${target.text()}.sendFile(${pathArg.text()}, { ${DOTFILES_OPTION} }, ${second.text()})`))
}
}

if (!edits.length) return null

return rootNode.commitEdits(edits)
}

interface TransformedOptions {
text: string
// True when the resulting object already carries a `dotfiles` key (either
// present originally or produced by renaming a removed `hidden` option), so
// the default `dotfiles: 'allow'` should NOT be appended.
hasDotfiles: boolean
}

// Rewrites the options object for Express 5:
// - renames the removed `hidden` option to `dotfiles` (true -> 'allow', false -> 'ignore')
// - renames the removed `from` option to `root`
// Returns null when the argument isn't an object literal.
function transformOptions(optsArg: SgNode<Js>): TransformedOptions | null {
if (!optsArg.is('object')) return null

const edits: Edit[] = []
const pairs = optsArg.children().filter((pair: SgNode<Js>) => pair.is('pair'))

// An explicit `dotfiles` key always wins: we never append a default and never
// rename a `hidden` onto it (which would produce a duplicate `dotfiles` key).
const hasExplicitDotfiles = pairs.some((pair) => getOptionKeyName(pair) === 'dotfiles')

// A present `hidden` (even non-literal) maps onto `dotfiles`, so a default
// must not be appended even when we can't rewrite its value.
let hasDotfiles = hasExplicitDotfiles

for (const pair of pairs) {
const keyName = getOptionKeyName(pair)

if (keyName === 'hidden') {
hasDotfiles = true

if (hasExplicitDotfiles) continue

const valueNode = pair.field('value')
const mapped = valueNode ? mapHiddenValue(valueNode.text()) : null
if (mapped) edits.push(pair.replace(`dotfiles: ${mapped}`))
continue
}

if (keyName === 'from') {
const keyNode = pair.field('key')
if (keyNode) edits.push(keyNode.replace('root'))
}
}

const text = edits.length ? optsArg.commitEdits(edits) : optsArg.text()
return { text, hasDotfiles }
}

function getOptionKeyName(pair: SgNode<Js>): string | null {
const keyNode = pair.field('key')
if (!keyNode) return null

return keyNode.is('string') ? getStringLiteralValue(keyNode) : keyNode.text()
}

function mapHiddenValue(valueText: string): string | null {
const trimmed = valueText.trim()
if (trimmed === 'true') return "'allow'"
if (trimmed === 'false') return "'ignore'"

return null
}

function getStringLiteralValue(node: SgNode<Js> | null | undefined): string | null {
if (!node || !node.is('string')) return null

const text = node.text()
if (text.length < 2) return null

return text.slice(1, -1)
}

function addDotfilesOption(optsText: string): string {
const trimmed = optsText.trimEnd()

if (!trimmed.includes('\n')) {
const inner = trimmed.slice(1, -1).trim()

return inner ? `{ ${inner}, ${DOTFILES_OPTION} }` : `{ ${DOTFILES_OPTION} }`
}

const closingBraceIndex = trimmed.lastIndexOf('}')
const body = trimmed.slice(0, closingBraceIndex).trimEnd()
const closingIndent = getIndentAfterLastNewline(trimmed)
const propertyIndent = getIndentAfterLastNewline(body) || ' '

return `${body}\n${propertyIndent}${DOTFILES_OPTION}\n${closingIndent}}`
}

function isCallback(node: SgNode<Js>): boolean {
return node.is('arrow_function') || node.is('function_expression')
}

function isResponseBinding(binding: SgNode<Js>): boolean {
const definition = binding.definition({ resolveExternal: false })
if (!definition) return false

return definition.node.matches({
rule: { inside: { kind: 'formal_parameters', stopBy: 'end' } },
})
}

function getIndentAfterLastNewline(text: string): string {
const newlineIndex = text.lastIndexOf('\n')
if (newlineIndex === -1) return ''

let indent = ''
for (let index = newlineIndex + 1; index < text.length; index++) {
const char = text[index]
if (char !== ' ' && char !== '\t') break
indent += char
}

return indent
}

export default transform
20 changes: 20 additions & 0 deletions codemods/sendfile-options/tests/expected/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const express = require("express");
const app = express();

app.get('/multi', function (req, res) {
res.sendFile(
'index.html',
{
maxAge: '1d',
dotfiles: 'allow' /* Express 5: preserve v4 behavior */
}
);
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.use((req, res, next) => {
res.sendFile('error.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

function handler(req, res) {
res.sendFile('page.html', { root: '/pages', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ });
}
56 changes: 56 additions & 0 deletions codemods/sendfile-options/tests/expected/sendfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import express from "express";

const app = express();
const serveHidden = true;

app.get('/a', (req, res) => {
res.sendFile('index.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/b', (req, res) => {
res.sendFile('index.html', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/c', (req, res) => {
res.sendFile('index.html', { dotfiles: 'deny' });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/d', (req, res) => {
res.sendFile('file.html', { dotfiles: 'allow' });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/e', (req, res) => {
res.sendFile('file.html', { dotfiles: 'ignore' });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/f', (req, res) => {
res.sendFile('file.html', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/g', (req, res) => {
res.sendFile('file.html', { dotfiles: 'allow', root: '/uploads', maxAge: '1d' });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/h', (req, res) => {
res.sendFile('file.html', { hidden: serveHidden });
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/callback', (req, res) => {
res.sendFile('index.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }, (err) => {
if (err) console.error(err);
});
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/options-callback', (req, res) => {
res.sendFile('index.html', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }, (err) => {});
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

app.get('/variable-options', (req, res) => {
const opts = { maxAge: '1d' };
res.sendFile('index.html', opts);
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

const notRes = {
sendFile(_path: string) {},
};
notRes.sendFile('index.html');
Loading
Loading