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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
59 changes: 59 additions & 0 deletions codemods/static-dotfiles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Migrate `express.static` options

Express 5 changes several `express.static` options:

- 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.
- 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 `express.static()` 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`.

## Example

```diff
- app.use(express.static('public'))
+ app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
```

### With existing options

```diff
- app.use(express.static('public', { maxAge: '1d' }))
+ app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
```

### Removed `hidden` option

```diff
- app.use(express.static('public', { hidden: true }))
+ app.use(express.static('public', { dotfiles: 'allow' }))
```

### Removed `from` option

```diff
- app.use(express.static('uploads', { from: '/uploads' }))
+ app.use(express.static('uploads', { root: '/uploads', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }))
```

## Security Consideration

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:

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

For directories like `.well-known` that need to be served (e.g., for Android App Links or Apple Universal Links), consider serving them explicitly:

```javascript
app.use('/.well-known', express.static('public/.well-known', { dotfiles: 'allow' }))
app.use(express.static('public'))
```

## References

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

targets:
languages:
- javascript
- typescript

keywords:
- transformation
- migration
- express
- static
- dotfiles
- express.static

registry:
access: public
visibility: public
22 changes: 22 additions & 0 deletions codemods/static-dotfiles/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@expressjs/static-dotfiles",
"private": true,
"version": "1.0.0",
"description": "Migrates express.static() 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/static-dotfiles",
"bugs": "https://github.com/expressjs/codemod/issues"
},
"author": "Vishal Kumar Singh",
"license": "MIT",
"homepage": "https://github.com/expressjs/codemod/blob/main/codemods/static-dotfiles/README.md",
"devDependencies": {
"@codemod.com/jssg-types": "^1.5.0"
}
}
223 changes: 223 additions & 0 deletions codemods/static-dotfiles/src/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
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 */"

async function transform(root: SgRoot<Js>): Promise<string | null> {
const rootNode = root.root()
Comment thread
singhvishalkr marked this conversation as resolved.
const edits: Edit[] = []

const nodes = rootNode.findAll({
rule: {
any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }],
},
})

if (!nodes.length) return null

for (const call of nodes) {
const target = call.getMatch('CALL')
const pathArg = call.getMatch('PATH')
const optsArg = call.getMatch('OPTS')

if (!target || !pathArg) continue

if (!isExpressBinding(target)) continue

if (!optsArg) {
edits.push(call.replace(`${target.text()}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`))
continue
}

const result = transformOptions(optsArg)
// Skip anything that isn't an object literal (e.g. a variable reference);
// we can't safely rewrite options we can't see.
if (!result) continue

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

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

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

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 isExpressBinding(binding: SgNode<Js>): boolean {
if (binding.is('call_expression')) {
return isExpressRequireCall(binding)
}

const definition = binding.definition({ resolveExternal: false })
if (!definition) return false

return isExpressDefinition(definition.node)
}

function isExpressDefinition(node: SgNode<Js>): boolean {
const importStatement = findAncestorOrSelf(node, 'import_statement')
if (importStatement) {
return isExpressImport(importStatement)
}

const declarator = findAncestorOrSelf(node, 'variable_declarator')
if (declarator) {
return isExpressRequireDeclarator(declarator)
}

return false
}

function isExpressImport(importStatement: SgNode<Js>): boolean {
const source = importStatement.field('source')
return getStringLiteralValue(source) === 'express'
}

function isExpressRequireDeclarator(declarator: SgNode<Js>): boolean {
if (!declarator.is('variable_declarator')) return false

const value = declarator.field('value')
if (!value?.is('call_expression')) return false

return isExpressRequireCall(value)
}

function isExpressRequireCall(node: SgNode<Js>): boolean {
const callFunction = node.field('function')
if (!callFunction?.is('identifier') || callFunction.text() !== 'require') return false

const args = node.field('arguments')
if (!args) return false

const expressSource = args.children().find((child) => child.is('string'))
return getStringLiteralValue(expressSource) === 'express'
}

function findAncestorOrSelf(node: SgNode<Js>, kind: string): SgNode<Js> | null {
let current: SgNode<Js> | null = node

while (current) {
// Assign to a typed boolean so `is()`'s type predicate doesn't narrow
// `current` to `never` on the following line.
const matches: boolean = current.is(kind)
if (matches) return current

current = current.parent()
}

return null
}

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
45 changes: 45 additions & 0 deletions codemods/static-dotfiles/tests/expected/aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import staticExpress from "express";
import * as expressNS from "express";
import otherLib from "other-lib";

const expressRequire = require("express");

const aliasedExpress = staticExpress;

const app = {
use() {},
};

app.use(staticExpress.static('aliased', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));

app.use(
staticExpress.static(
'multi-line',
{
maxAge: '1d',
dotfiles: 'allow' /* Express 5: preserve v4 behavior */
}
)
);

app.use(
staticExpress.static(
'multi-line-options',
{
root: '/uploads',
dotfiles: 'allow',
}
)
);

app.use(expressNS.static('namespace', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));

app.use(expressRequire.static('commonjs', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));

app.use(require("express").static('direct-require', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ }));

// Not express: must be left untouched.
app.use(otherLib.static('not-express'));

// Indirect alias (assignment, not import/require): conservatively left untouched.
app.use(aliasedExpress.static('indirect-alias'));
Loading