Skip to content

Commit cab8746

Browse files
feat: add express.static dotfiles codemod (#142)
* feat: add express.static dotfiles codemod Add a codemod that explicitly sets dotfiles: 'allow' on express.static() calls to preserve Express 4 behavior where dotfiles were served by default. In Express 5, the dotfiles option defaults to 'ignore', which can break functionality that depends on serving dot-directories like .well-known (used by Android App Links and Apple Universal Links). Closes #116 * feat(static-dotfiles): resolve express aliases Detect default, namespace, and CommonJS express bindings before rewriting static() calls. Add a multiline fixture to cover indentation-sensitive options objects. * chore(static-dotfiles): bump codemod version to 1.1.0 Match the reviewer request to bump the codemod minor version alongside the alias-handling fix. * fix(static-dotfiles): resolve express aliases without regex * test: keep codemod fixtures LF on Windows Force text checkouts to LF so ast-grep fixture comparisons stay stable on Windows runners. This fixes the static-dotfiles multiline fixture mismatch without changing the transform logic. Signed-off-by: Vishal Kumar Singh <vishal.kr.singh2021@gmail.com> * docs(static-dotfiles): clarify examples with diffs * feat(static-dotfiles): migrate express.static options to Express 5, updating dotfiles behavior and renaming removed options * fixup! --------- Signed-off-by: Vishal Kumar Singh <vishal.kr.singh2021@gmail.com> Co-authored-by: Sebastian Beltran <bjohansebas@gmail.com>
1 parent b41f6e6 commit cab8746

12 files changed

Lines changed: 546 additions & 1 deletion

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* text=auto eol=lf

codemods/static-dotfiles/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Migrate `express.static` options
2+
3+
Express 5 changes several `express.static` options:
4+
5+
- The `dotfiles` option now defaults to `"ignore"` (Express 4 served dotfiles by default). Files inside a directory that starts with a dot (`.`), such as `.well-known`, will no longer be accessible and will return a 404 Not Found error.
6+
- The `hidden` option is removed and replaced by `dotfiles`.
7+
- The `from` option (an undocumented alias for `root`) is removed and replaced by `root`.
8+
9+
This codemod updates `express.static()` calls to preserve the Express 4 behavior:
10+
11+
1. Adds an explicit `dotfiles: 'allow'` option to calls that don't already specify a `dotfiles` (or `hidden`) option.
12+
2. Renames `hidden` to `dotfiles` (`hidden: true``dotfiles: 'allow'`, `hidden: false``dotfiles: 'ignore'`).
13+
3. Renames `from` to `root`.
14+
15+
## Example
16+
17+
```diff
18+
- app.use(express.static('public'))
19+
+ app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
20+
```
21+
22+
### With existing options
23+
24+
```diff
25+
- app.use(express.static('public', { maxAge: '1d' }))
26+
+ app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
27+
```
28+
29+
### Removed `hidden` option
30+
31+
```diff
32+
- app.use(express.static('public', { hidden: true }))
33+
+ app.use(express.static('public', { dotfiles: 'allow' }))
34+
```
35+
36+
### Removed `from` option
37+
38+
```diff
39+
- app.use(express.static('uploads', { from: '/uploads' }))
40+
+ app.use(express.static('uploads', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
41+
```
42+
43+
## Security Consideration
44+
45+
After running this codemod, review each `express.static()` call to determine if serving dotfiles is actually necessary for your application. If you don't need to serve dotfiles, you can:
46+
47+
1. Remove the `dotfiles: 'allow'` option to use the new Express 5 default (`"ignore"`)
48+
2. Or explicitly set `dotfiles: 'deny'` to return a 403 Forbidden for dotfile requests
49+
50+
For directories like `.well-known` that need to be served (e.g., for Android App Links or Apple Universal Links), consider serving them explicitly:
51+
52+
```javascript
53+
app.use('/.well-known', express.static('public/.well-known', { dotfiles: 'allow' }))
54+
app.use(express.static('public'))
55+
```
56+
57+
## References
58+
59+
- [Express 5 Migration Guide - express.static dotfiles](https://expressjs.com/en/guide/migrating-5#expressstatic-options)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
schema_version: "1.0"
2+
name: "@expressjs/static-dotfiles"
3+
version: "1.0.0"
4+
description: Migrates express.static() options to Express 5 - adds an explicit dotfiles option and renames the removed hidden and from options
5+
author: Vishal Kumar Singh
6+
license: MIT
7+
workflow: workflow.yaml
8+
repository: "https://github.com/expressjs/codemod/tree/HEAD/codemods/static-dotfiles"
9+
category: migration
10+
11+
targets:
12+
languages:
13+
- javascript
14+
- typescript
15+
16+
keywords:
17+
- transformation
18+
- migration
19+
- express
20+
- static
21+
- dotfiles
22+
- express.static
23+
24+
registry:
25+
access: public
26+
visibility: public
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@expressjs/static-dotfiles",
3+
"private": true,
4+
"version": "1.0.0",
5+
"description": "Migrates express.static() options to Express 5: adds an explicit dotfiles option and renames the removed hidden and from options",
6+
"type": "module",
7+
"scripts": {
8+
"test": "npx codemod jssg test -l typescript ./src/workflow.ts ./"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/expressjs/codemod.git",
13+
"directory": "codemods/static-dotfiles",
14+
"bugs": "https://github.com/expressjs/codemod/issues"
15+
},
16+
"author": "Vishal Kumar Singh",
17+
"license": "MIT",
18+
"homepage": "https://github.com/expressjs/codemod/blob/main/codemods/static-dotfiles/README.md",
19+
"devDependencies": {
20+
"@codemod.com/jssg-types": "^1.5.0"
21+
}
22+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import type Js from '@codemod.com/jssg-types/src/langs/javascript'
2+
import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/src/main'
3+
4+
const DOTFILES_OPTION = "dotfiles: 'allow' /* Express 5: preserve v4 behavior */"
5+
6+
async function transform(root: SgRoot<Js>): Promise<string | null> {
7+
const rootNode = root.root()
8+
const edits: Edit[] = []
9+
10+
const nodes = rootNode.findAll({
11+
rule: {
12+
any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }],
13+
},
14+
})
15+
16+
if (!nodes.length) return null
17+
18+
for (const call of nodes) {
19+
const target = call.getMatch('CALL')
20+
const pathArg = call.getMatch('PATH')
21+
const optsArg = call.getMatch('OPTS')
22+
23+
if (!target || !pathArg) continue
24+
25+
if (!isExpressBinding(target)) continue
26+
27+
if (!optsArg) {
28+
edits.push(call.replace(`${target.text()}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`))
29+
continue
30+
}
31+
32+
const result = transformOptions(optsArg)
33+
// Skip anything that isn't an object literal (e.g. a variable reference);
34+
// we can't safely rewrite options we can't see.
35+
if (!result) continue
36+
37+
let newOpts = result.text
38+
if (!result.hasDotfiles) {
39+
newOpts = addDotfilesOption(newOpts)
40+
}
41+
42+
const originalOpts = optsArg.text()
43+
if (newOpts === originalOpts) continue
44+
45+
edits.push(call.replace(call.text().replace(originalOpts, newOpts)))
46+
}
47+
48+
if (!edits.length) return null
49+
50+
return rootNode.commitEdits(edits)
51+
}
52+
53+
interface TransformedOptions {
54+
text: string
55+
// True when the resulting object already carries a `dotfiles` key (either
56+
// present originally or produced by renaming a removed `hidden` option), so
57+
// the default `dotfiles: 'allow'` should NOT be appended.
58+
hasDotfiles: boolean
59+
}
60+
61+
// Rewrites the options object for Express 5:
62+
// - renames the removed `hidden` option to `dotfiles` (true -> 'allow', false -> 'ignore')
63+
// - renames the removed `from` option to `root`
64+
// Returns null when the argument isn't an object literal.
65+
function transformOptions(optsArg: SgNode<Js>): TransformedOptions | null {
66+
if (!optsArg.is('object')) return null
67+
68+
const edits: Edit[] = []
69+
const pairs = optsArg.children().filter((pair: SgNode<Js>) => pair.is('pair'))
70+
71+
// An explicit `dotfiles` key always wins: we never append a default and never
72+
// rename a `hidden` onto it (which would produce a duplicate `dotfiles` key).
73+
const hasExplicitDotfiles = pairs.some((pair) => getOptionKeyName(pair) === 'dotfiles')
74+
75+
// A present `hidden` (even non-literal) maps onto `dotfiles`, so a default
76+
// must not be appended even when we can't rewrite its value.
77+
let hasDotfiles = hasExplicitDotfiles
78+
79+
for (const pair of pairs) {
80+
const keyName = getOptionKeyName(pair)
81+
82+
if (keyName === 'hidden') {
83+
hasDotfiles = true
84+
85+
if (hasExplicitDotfiles) continue
86+
87+
const valueNode = pair.field('value')
88+
const mapped = valueNode ? mapHiddenValue(valueNode.text()) : null
89+
if (mapped) edits.push(pair.replace(`dotfiles: ${mapped}`))
90+
continue
91+
}
92+
93+
if (keyName === 'from') {
94+
const keyNode = pair.field('key')
95+
if (keyNode) edits.push(keyNode.replace('root'))
96+
}
97+
}
98+
99+
const text = edits.length ? optsArg.commitEdits(edits) : optsArg.text()
100+
return { text, hasDotfiles }
101+
}
102+
103+
function getOptionKeyName(pair: SgNode<Js>): string | null {
104+
const keyNode = pair.field('key')
105+
if (!keyNode) return null
106+
107+
return keyNode.is('string') ? getStringLiteralValue(keyNode) : keyNode.text()
108+
}
109+
110+
function mapHiddenValue(valueText: string): string | null {
111+
const trimmed = valueText.trim()
112+
if (trimmed === 'true') return "'allow'"
113+
if (trimmed === 'false') return "'ignore'"
114+
115+
return null
116+
}
117+
118+
function getStringLiteralValue(node: SgNode<Js> | null | undefined): string | null {
119+
if (!node || !node.is('string')) return null
120+
121+
const text = node.text()
122+
if (text.length < 2) return null
123+
124+
return text.slice(1, -1)
125+
}
126+
127+
function addDotfilesOption(optsText: string): string {
128+
const trimmed = optsText.trimEnd()
129+
130+
if (!trimmed.includes('\n')) {
131+
const inner = trimmed.slice(1, -1).trim()
132+
133+
return inner ? `{ ${inner}, ${DOTFILES_OPTION} }` : `{ ${DOTFILES_OPTION} }`
134+
}
135+
136+
const closingBraceIndex = trimmed.lastIndexOf('}')
137+
const body = trimmed.slice(0, closingBraceIndex).trimEnd()
138+
const closingIndent = getIndentAfterLastNewline(trimmed)
139+
const propertyIndent = getIndentAfterLastNewline(body) || ' '
140+
141+
return `${body}\n${propertyIndent}${DOTFILES_OPTION}\n${closingIndent}}`
142+
}
143+
144+
function isExpressBinding(binding: SgNode<Js>): boolean {
145+
if (binding.is('call_expression')) {
146+
return isExpressRequireCall(binding)
147+
}
148+
149+
const definition = binding.definition({ resolveExternal: false })
150+
if (!definition) return false
151+
152+
return isExpressDefinition(definition.node)
153+
}
154+
155+
function isExpressDefinition(node: SgNode<Js>): boolean {
156+
const importStatement = findAncestorOrSelf(node, 'import_statement')
157+
if (importStatement) {
158+
return isExpressImport(importStatement)
159+
}
160+
161+
const declarator = findAncestorOrSelf(node, 'variable_declarator')
162+
if (declarator) {
163+
return isExpressRequireDeclarator(declarator)
164+
}
165+
166+
return false
167+
}
168+
169+
function isExpressImport(importStatement: SgNode<Js>): boolean {
170+
const source = importStatement.field('source')
171+
return getStringLiteralValue(source) === 'express'
172+
}
173+
174+
function isExpressRequireDeclarator(declarator: SgNode<Js>): boolean {
175+
if (!declarator.is('variable_declarator')) return false
176+
177+
const value = declarator.field('value')
178+
if (!value?.is('call_expression')) return false
179+
180+
return isExpressRequireCall(value)
181+
}
182+
183+
function isExpressRequireCall(node: SgNode<Js>): boolean {
184+
const callFunction = node.field('function')
185+
if (!callFunction?.is('identifier') || callFunction.text() !== 'require') return false
186+
187+
const args = node.field('arguments')
188+
if (!args) return false
189+
190+
const expressSource = args.children().find((child) => child.is('string'))
191+
return getStringLiteralValue(expressSource) === 'express'
192+
}
193+
194+
function findAncestorOrSelf(node: SgNode<Js>, kind: string): SgNode<Js> | null {
195+
let current: SgNode<Js> | null = node
196+
197+
while (current) {
198+
// Assign to a typed boolean so `is()`'s type predicate doesn't narrow
199+
// `current` to `never` on the following line.
200+
const matches: boolean = current.is(kind)
201+
if (matches) return current
202+
203+
current = current.parent()
204+
}
205+
206+
return null
207+
}
208+
209+
function getIndentAfterLastNewline(text: string): string {
210+
const newlineIndex = text.lastIndexOf('\n')
211+
if (newlineIndex === -1) return ''
212+
213+
let indent = ''
214+
for (let index = newlineIndex + 1; index < text.length; index++) {
215+
const char = text[index]
216+
if (char !== ' ' && char !== '\t') break
217+
indent += char
218+
}
219+
220+
return indent
221+
}
222+
223+
export default transform
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import staticExpress from "express";
2+
import * as expressNS from "express";
3+
import otherLib from "other-lib";
4+
5+
const expressRequire = require("express");
6+
7+
const aliasedExpress = staticExpress;
8+
9+
const app = {
10+
use() {},
11+
};
12+
13+
app.use(staticExpress.static('aliased', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));
14+
15+
app.use(
16+
staticExpress.static(
17+
'multi-line',
18+
{
19+
maxAge: '1d',
20+
dotfiles: 'allow' /* Express 5: preserve v4 behavior */
21+
}
22+
)
23+
);
24+
25+
app.use(
26+
staticExpress.static(
27+
'multi-line-options',
28+
{
29+
root: '/uploads',
30+
dotfiles: 'allow',
31+
}
32+
)
33+
);
34+
35+
app.use(expressNS.static('namespace', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));
36+
37+
app.use(expressRequire.static('commonjs', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));
38+
39+
app.use(require("express").static('direct-require', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));
40+
41+
// Not express: must be left untouched.
42+
app.use(otherLib.static('not-express'));
43+
44+
// Indirect alias (assignment, not import/require): conservatively left untouched.
45+
app.use(aliasedExpress.static('indirect-alias'));

0 commit comments

Comments
 (0)