diff --git a/codemods/sendfile-options/README.md b/codemods/sendfile-options/README.md new file mode 100644 index 0000000..dfc9773 --- /dev/null +++ b/codemods/sendfile-options/README.md @@ -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) diff --git a/codemods/sendfile-options/codemod.yaml b/codemods/sendfile-options/codemod.yaml new file mode 100644 index 0000000..470c75e --- /dev/null +++ b/codemods/sendfile-options/codemod.yaml @@ -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 +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 diff --git a/codemods/sendfile-options/package.json b/codemods/sendfile-options/package.json new file mode 100644 index 0000000..1d0218c --- /dev/null +++ b/codemods/sendfile-options/package.json @@ -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 ", + "license": "MIT", + "homepage": "https://github.com/expressjs/codemod/blob/main/codemods/sendfile-options/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } +} diff --git a/codemods/sendfile-options/src/workflow.ts b/codemods/sendfile-options/src/workflow.ts new file mode 100644 index 0000000..fbb7eb4 --- /dev/null +++ b/codemods/sendfile-options/src/workflow.ts @@ -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): Promise { + 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): TransformedOptions | null { + if (!optsArg.is('object')) return null + + const edits: Edit[] = [] + const pairs = optsArg.children().filter((pair: SgNode) => 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): 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 | 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): boolean { + return node.is('arrow_function') || node.is('function_expression') +} + +function isResponseBinding(binding: SgNode): 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 diff --git a/codemods/sendfile-options/tests/expected/handlers.ts b/codemods/sendfile-options/tests/expected/handlers.ts new file mode 100644 index 0000000..ca69d2e --- /dev/null +++ b/codemods/sendfile-options/tests/expected/handlers.ts @@ -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 */ + } + ); +}); + +app.use((req, res, next) => { + res.sendFile('error.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }); +}); + +function handler(req, res) { + res.sendFile('page.html', { root: '/pages', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }); +} diff --git a/codemods/sendfile-options/tests/expected/sendfile.ts b/codemods/sendfile-options/tests/expected/sendfile.ts new file mode 100644 index 0000000..0f438c9 --- /dev/null +++ b/codemods/sendfile-options/tests/expected/sendfile.ts @@ -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 */ }); +}); + +app.get('/b', (req, res) => { + res.sendFile('index.html', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }); +}); + +app.get('/c', (req, res) => { + res.sendFile('index.html', { dotfiles: 'deny' }); +}); + +app.get('/d', (req, res) => { + res.sendFile('file.html', { dotfiles: 'allow' }); +}); + +app.get('/e', (req, res) => { + res.sendFile('file.html', { dotfiles: 'ignore' }); +}); + +app.get('/f', (req, res) => { + res.sendFile('file.html', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }); +}); + +app.get('/g', (req, res) => { + res.sendFile('file.html', { dotfiles: 'allow', root: '/uploads', maxAge: '1d' }); +}); + +app.get('/h', (req, res) => { + res.sendFile('file.html', { hidden: serveHidden }); +}); + +app.get('/callback', (req, res) => { + res.sendFile('index.html', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }, (err) => { + if (err) console.error(err); + }); +}); + +app.get('/options-callback', (req, res) => { + res.sendFile('index.html', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }, (err) => {}); +}); + +app.get('/variable-options', (req, res) => { + const opts = { maxAge: '1d' }; + res.sendFile('index.html', opts); +}); + +const notRes = { + sendFile(_path: string) {}, +}; +notRes.sendFile('index.html'); diff --git a/codemods/sendfile-options/tests/input/handlers.ts b/codemods/sendfile-options/tests/input/handlers.ts new file mode 100644 index 0000000..52c0055 --- /dev/null +++ b/codemods/sendfile-options/tests/input/handlers.ts @@ -0,0 +1,19 @@ +const express = require("express"); +const app = express(); + +app.get('/multi', function (req, res) { + res.sendFile( + 'index.html', + { + maxAge: '1d', + } + ); +}); + +app.use((req, res, next) => { + res.sendFile('error.html'); +}); + +function handler(req, res) { + res.sendFile('page.html', { from: '/pages' }); +} diff --git a/codemods/sendfile-options/tests/input/sendfile.ts b/codemods/sendfile-options/tests/input/sendfile.ts new file mode 100644 index 0000000..8d1fec1 --- /dev/null +++ b/codemods/sendfile-options/tests/input/sendfile.ts @@ -0,0 +1,56 @@ +import express from "express"; + +const app = express(); +const serveHidden = true; + +app.get('/a', (req, res) => { + res.sendFile('index.html'); +}); + +app.get('/b', (req, res) => { + res.sendFile('index.html', { maxAge: '1d' }); +}); + +app.get('/c', (req, res) => { + res.sendFile('index.html', { dotfiles: 'deny' }); +}); + +app.get('/d', (req, res) => { + res.sendFile('file.html', { hidden: true }); +}); + +app.get('/e', (req, res) => { + res.sendFile('file.html', { hidden: false }); +}); + +app.get('/f', (req, res) => { + res.sendFile('file.html', { from: '/uploads' }); +}); + +app.get('/g', (req, res) => { + res.sendFile('file.html', { hidden: true, from: '/uploads', maxAge: '1d' }); +}); + +app.get('/h', (req, res) => { + res.sendFile('file.html', { hidden: serveHidden }); +}); + +app.get('/callback', (req, res) => { + res.sendFile('index.html', (err) => { + if (err) console.error(err); + }); +}); + +app.get('/options-callback', (req, res) => { + res.sendFile('index.html', { maxAge: '1d' }, (err) => {}); +}); + +app.get('/variable-options', (req, res) => { + const opts = { maxAge: '1d' }; + res.sendFile('index.html', opts); +}); + +const notRes = { + sendFile(_path: string) {}, +}; +notRes.sendFile('index.html'); diff --git a/codemods/sendfile-options/workflow.yaml b/codemods/sendfile-options/workflow.yaml new file mode 100644 index 0000000..794cfc9 --- /dev/null +++ b/codemods/sendfile-options/workflow.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Adds explicit dotfiles option to res.sendFile() calls to preserve Express 4 behavior + js-ast-grep: + js_file: src/workflow.ts + base_path: . + semantic_analysis: file + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript diff --git a/codemods/v5-migration-recipe/workflow.yaml b/codemods/v5-migration-recipe/workflow.yaml index 6c95ef9..c905fe0 100644 --- a/codemods/v5-migration-recipe/workflow.yaml +++ b/codemods/v5-migration-recipe/workflow.yaml @@ -35,4 +35,7 @@ nodes: source: "@expressjs/static-dotfiles" - name: Migrates express.static.mime to the mime-types package codemod: - source: "@expressjs/static-mime" \ No newline at end of file + source: "@expressjs/static-mime" + - name: Adds explicit dotfiles option to res.sendFile() calls to preserve Express 4 behavior + codemod: + source: "@expressjs/sendfile-options" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d241be9..a26aa8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,14 @@ "@codemod.com/jssg-types": "^1.5.0" } }, + "codemods/sendfile-options": { + "name": "@expressjs/sendfile-options", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } + }, "codemods/static-dotfiles": { "name": "@expressjs/static-dotfiles", "version": "1.0.0", @@ -294,6 +302,10 @@ "resolved": "codemods/route-del-to-delete", "link": true }, + "node_modules/@expressjs/sendfile-options": { + "resolved": "codemods/sendfile-options", + "link": true + }, "node_modules/@expressjs/static-dotfiles": { "resolved": "codemods/static-dotfiles", "link": true