diff --git a/codemods/static-dotfiles/README.md b/codemods/static-dotfiles/README.md new file mode 100644 index 0000000..1f602ef --- /dev/null +++ b/codemods/static-dotfiles/README.md @@ -0,0 +1,51 @@ +# Migrate `express.static` dotfiles behavior + +In Express 5, the `express.static` middleware's `dotfiles` option now defaults to `"ignore"`. This is a change from Express 4, where dotfiles were served by default. As a result, 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. + +This codemod adds an explicit `dotfiles: 'allow'` option to `express.static()` calls that don't already specify a `dotfiles` option, preserving the Express 4 behavior. + +## Example + +### Before + +```javascript +app.use(express.static('public')) +``` + +### After + +```javascript +app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })) +``` + +### With existing options + +#### Before + +```javascript +app.use(express.static('public', { maxAge: '1d' })) +``` + +#### After + +```javascript +app.use(express.static('public', { maxAge: '1d', 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.html#express.static.dotfiles) diff --git a/codemods/static-dotfiles/codemod.yaml b/codemods/static-dotfiles/codemod.yaml new file mode 100644 index 0000000..8c02d25 --- /dev/null +++ b/codemods/static-dotfiles/codemod.yaml @@ -0,0 +1,26 @@ +schema_version: "1.0" +name: "@expressjs/static-dotfiles" +version: "1.0.0" +description: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default +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 diff --git a/codemods/static-dotfiles/package.json b/codemods/static-dotfiles/package.json new file mode 100644 index 0000000..395de9f --- /dev/null +++ b/codemods/static-dotfiles/package.json @@ -0,0 +1,22 @@ +{ + "name": "@expressjs/static-dotfiles", + "private": true, + "version": "1.1.0", + "description": "Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior where dotfiles were served by default", + "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" + } +} diff --git a/codemods/static-dotfiles/src/workflow.ts b/codemods/static-dotfiles/src/workflow.ts new file mode 100644 index 0000000..33eb9df --- /dev/null +++ b/codemods/static-dotfiles/src/workflow.ts @@ -0,0 +1,147 @@ +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): Promise { + const rootNode = root.root() + + const nodes = rootNode.findAll({ + rule: { + any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }], + }, + }) + + if (!nodes.length) return null + + const edits: Edit[] = [] + + 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) { + const optsText = optsArg.text() + if (optsText.includes('dotfiles')) { + continue + } + + const newOpts = addDotfilesOption(optsText) + edits.push(call.replace(call.text().replace(optsText, newOpts))) + } else { + edits.push(call.replace(`${target.text()}.static(${pathArg.text()}, { ${DOTFILES_OPTION} })`)) + } + } + + if (!edits.length) return null + + return rootNode.commitEdits(edits) +} + +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 isExpressBinding(binding: SgNode): 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): 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): boolean { + const source = importStatement.field('source') + return getStringLiteralValue(source) === 'express' +} + +function isExpressRequireDeclarator(declarator: SgNode): 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): 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, kind: string): SgNode | null { + let current: SgNode | null = node + + while (current) { + if (current.is(kind)) 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 diff --git a/codemods/static-dotfiles/tests/expected/aliases.ts b/codemods/static-dotfiles/tests/expected/aliases.ts new file mode 100644 index 0000000..311798f --- /dev/null +++ b/codemods/static-dotfiles/tests/expected/aliases.ts @@ -0,0 +1,26 @@ +import staticExpress from "express"; +import * as expressNS from "express"; + +const expressRequire = require("express"); + +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(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 */ })); diff --git a/codemods/static-dotfiles/tests/expected/static.ts b/codemods/static-dotfiles/tests/expected/static.ts new file mode 100644 index 0000000..3abfb93 --- /dev/null +++ b/codemods/static-dotfiles/tests/expected/static.ts @@ -0,0 +1,22 @@ +import express from "express"; + +const app = express(); + +app.use(express.static('public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('assets', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use('/files', express.static('uploads', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { maxAge: '1d', dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { index: false, maxAge: 86400000, dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static('public', { dotfiles: 'deny' })); + +app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); + +const staticPath = './static'; +app.use(express.static(staticPath, { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); + +app.use(express.static(__dirname + '/public', { dotfiles: 'allow' /* Express 5: preserve v4 behavior */ })); diff --git a/codemods/static-dotfiles/tests/input/aliases.ts b/codemods/static-dotfiles/tests/input/aliases.ts new file mode 100644 index 0000000..5c920cc --- /dev/null +++ b/codemods/static-dotfiles/tests/input/aliases.ts @@ -0,0 +1,25 @@ +import staticExpress from "express"; +import * as expressNS from "express"; + +const expressRequire = require("express"); + +const app = { + use() {}, +}; + +app.use(staticExpress.static('aliased')); + +app.use( + staticExpress.static( + 'multi-line', + { + maxAge: '1d', + } + ) +); + +app.use(expressNS.static('namespace')); + +app.use(expressRequire.static('commonjs')); + +app.use(require("express").static('direct-require')); diff --git a/codemods/static-dotfiles/tests/input/static.ts b/codemods/static-dotfiles/tests/input/static.ts new file mode 100644 index 0000000..fb64cac --- /dev/null +++ b/codemods/static-dotfiles/tests/input/static.ts @@ -0,0 +1,22 @@ +import express from "express"; + +const app = express(); + +app.use(express.static('public')); + +app.use(express.static('assets')); + +app.use('/files', express.static('uploads')); + +app.use(express.static('public', { maxAge: '1d' })); + +app.use(express.static('public', { index: false, maxAge: 86400000 })); + +app.use(express.static('public', { dotfiles: 'deny' })); + +app.use(express.static('public', { dotfiles: 'allow', maxAge: '1d' })); + +const staticPath = './static'; +app.use(express.static(staticPath)); + +app.use(express.static(__dirname + '/public')); diff --git a/codemods/static-dotfiles/workflow.yaml b/codemods/static-dotfiles/workflow.yaml new file mode 100644 index 0000000..7a2ecd1 --- /dev/null +++ b/codemods/static-dotfiles/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 express.static() 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 77ae2fd..dbdbd28 100644 --- a/codemods/v5-migration-recipe/workflow.yaml +++ b/codemods/v5-migration-recipe/workflow.yaml @@ -29,4 +29,7 @@ nodes: source: "@expressjs/camelcase-sendfile" - name: Migrates usage of the legacy APIs `app.del()` to `app.delete()` codemod: - source: "@expressjs/route-del-to-delete" \ No newline at end of file + source: "@expressjs/route-del-to-delete" + - name: Adds explicit dotfiles option to express.static() calls to preserve Express 4 behavior + codemod: + source: "@expressjs/static-dotfiles" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2d9f155..e6d7b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,14 @@ "@codemod.com/jssg-types": "^1.5.0" } }, + "codemods/static-dotfiles": { + "name": "@expressjs/static-dotfiles", + "version": "1.1.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } + }, "codemods/status-send-order": { "name": "@expressjs/status-send-order", "version": "1.0.0", @@ -278,6 +286,10 @@ "resolved": "codemods/route-del-to-delete", "link": true }, + "node_modules/@expressjs/static-dotfiles": { + "resolved": "codemods/static-dotfiles", + "link": true + }, "node_modules/@expressjs/status-send-order": { "resolved": "codemods/status-send-order", "link": true