-
-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add express.static dotfiles codemod #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3c4ce9a
a51feb2
559f3d9
a1a6b47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| 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: 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<Js>): Promise<string | null> { | ||||||||
| const rootNode = root.root() | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
|
||||||||
| const nodes = rootNode.findAll({ | ||||||||
| rule: { | ||||||||
| any: [{ pattern: '$CALL.static($PATH)' }, { pattern: '$CALL.static($PATH, $OPTS)' }], | ||||||||
| }, | ||||||||
| }) | ||||||||
|
|
||||||||
| if (!nodes.length) return null | ||||||||
|
|
||||||||
| const edits: Edit[] = [] | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
|
||||||||
| 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<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) { | ||||||||
| 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 | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 */ })); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 */ })); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. need to bump minor version of this codemod 😁
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I bumped the codemod package version to 1.1.0 in the follow-up commit. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO
diffcodeblock is easier to get what happened