Skip to content

Commit 1216111

Browse files
committed
Improve nginx map parsing, support default and regular expressions.
1 parent c421486 commit 1216111

4 files changed

Lines changed: 205 additions & 50 deletions

File tree

src/bin/buildRouter.mts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../index.mts';
1616
import type { ConfigMount } from './config/types.mts';
1717
import { dependencies } from './modules/dependencies.mts';
18+
import { Mapper, nginxTokenise } from './nginx.mts';
1819
import { render } from './template.mts';
1920

2021
export interface LogInfo {
@@ -100,25 +101,44 @@ export async function buildRouter(mount: ConfigMount[], log: (info: LogInfo) =>
100101
);
101102
break;
102103
case 'redirect-map':
103-
const norm = item.options.caseSensitive ? (v: string) => v : (v: string) => v.toLowerCase();
104-
const mapping = new Map<string, string>();
104+
const mapper = new Mapper(item.options.caseSensitive);
105105
if (typeof item.mapping === 'string') {
106-
for (const statement of nginxTokenise(await readFile(item.mapping, 'utf-8'))) {
107-
if (statement.length !== 2 || statement[0]![0] !== '/') {
106+
const content = await readFile(item.mapping, 'utf-8');
107+
tokens: for (const statement of nginxTokenise(content)) {
108+
const key = statement[0]!;
109+
if (key.literal) {
110+
switch (key.token) {
111+
case 'default':
112+
if (statement.length === 2) {
113+
mapper.setDefault(statement[1]!.token);
114+
continue tokens;
115+
}
116+
break;
117+
case 'hostnames':
118+
throw new Error('redirect-map does not support hostnames');
119+
case 'include':
120+
throw new Error('redirect-map does not support nested mapping files');
121+
case 'volatile':
122+
// ignore
123+
break;
124+
}
125+
}
126+
if (statement.length === 2) {
127+
mapper.add(key.token, statement[1]!.token);
128+
} else {
108129
throw new Error(
109-
`unexpected statement in mapping file: ${statement.map((p) => JSON.stringify(p)).join(' ')}`,
130+
`unknown statement in mapping file: ${statement.map((p) => JSON.stringify(p)).join(' ')}`,
110131
);
111132
}
112-
mapping.set(norm(statement[0]!), statement[1]!);
113133
}
114134
} else {
115135
for (const [k, v] of Object.entries(item.mapping)) {
116-
mapping.set(norm(k), v);
136+
mapper.add(k, v);
117137
}
118138
}
119139
router.use((req, res) => {
120-
const redirect = mapping.get(norm(req.url ?? '/'));
121-
if (redirect !== undefined && redirect !== (req.url ?? '/')) {
140+
const redirect = mapper.get(req.url ?? '/');
141+
if (redirect && redirect !== (req.url ?? '/')) {
122142
res.setHeader('location', redirect);
123143
res.statusCode = item.status;
124144
return res.end();
@@ -143,32 +163,3 @@ const getParam = (req: IncomingMessage) => (key: string) =>
143163
: key[0] === '?'
144164
? { _value: getQuery(req, key.substring(1)), _encoding: 'raw' }
145165
: { _value: getPathParameter(req, key), _encoding: 'raw' };
146-
147-
function* nginxTokenise(source: string) {
148-
let statement: string[] = [];
149-
const token =
150-
/(\s+|#[^\n]*)|(;)|(?:"((?:[^"\\]+|\\.)*)")|(?:'((?:[^'\\]+|\\.)*)')|((?:[^#;\s\\"']+|\\.)+)/y;
151-
while (token.lastIndex < source.length) {
152-
const m = token.exec(source);
153-
if (!m) {
154-
throw new Error('invalid nginx syntax');
155-
}
156-
const [, separator, semicolon, dquot, squot, nquot] = m;
157-
if (separator) {
158-
continue;
159-
}
160-
if (semicolon) {
161-
yield statement;
162-
statement = [];
163-
continue;
164-
}
165-
const part = dquot ?? squot ?? nquot;
166-
if (part === undefined) {
167-
throw new Error('nginx tokenisation error');
168-
}
169-
statement.push(part.replaceAll(/\\(.)/g, '$1'));
170-
}
171-
if (statement.length) {
172-
yield statement;
173-
}
174-
}

src/bin/buildRouter.test.mts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { join } from 'node:path';
12
import { requestHandler } from '../index.mts';
2-
import { makeTestTempDir, makeTestTempFile } from '../test-helpers/makeFileStructure.mts';
3+
import { makeTestTempDir } from '../test-helpers/makeFileStructure.mts';
34
import { responds } from '../test-helpers/responds.mts';
45
import { withServer } from '../test-helpers/withServer.mts';
56
import type { ConfigMount } from './config/types.mts';
@@ -308,10 +309,8 @@ describe('buildRouter', () => {
308309
});
309310
});
310311

311-
const TEST_MAPPING_FILE = makeTestTempFile(
312-
'map-',
313-
'redirects.map',
314-
`
312+
const NGINX_MAPS = makeTestTempDir('map-', {
313+
'syntax.map': `
315314
/foo /new-foo;
316315
# comment
317316
/bar\t/new-bar #comment
@@ -324,7 +323,15 @@ describe('buildRouter', () => {
324323
/s1 /escaped\\ space;
325324
/s2 "/quoted space";
326325
`,
327-
);
326+
'with-default.map': `
327+
/foo /one;
328+
default /other;
329+
`,
330+
'regex.map': `
331+
~*^/one/(.+)/end.(?<ext>.+)$ /new/$1.$ext;
332+
~^/case/(.+)/end.(?<ext>.+)$ /new/$1.$ext;
333+
`,
334+
});
328335

329336
it(
330337
'loads mappings from an nginx-formatted mapping file',
@@ -333,7 +340,7 @@ describe('buildRouter', () => {
333340
const router = await buildRouter([
334341
{
335342
type: 'redirect-map',
336-
mapping: getTyped(TEST_MAPPING_FILE),
343+
mapping: join(getTyped(NGINX_MAPS), 'syntax.map'),
337344
status: 307,
338345
options: { caseSensitive: false },
339346
},
@@ -368,6 +375,69 @@ describe('buildRouter', () => {
368375
});
369376
},
370377
);
378+
379+
it(
380+
'supports "default" in nginx-formatted mapping files',
381+
{ timeout: 3000 },
382+
async ({ getTyped }) => {
383+
const router = await buildRouter([
384+
{
385+
type: 'redirect-map',
386+
mapping: join(getTyped(NGINX_MAPS), 'with-default.map'),
387+
status: 307,
388+
options: { caseSensitive: false },
389+
},
390+
]);
391+
return withServer(router, async (url) => {
392+
await expect(
393+
fetch(url + '/foo', { redirect: 'manual' }),
394+
responds({ status: 307, headers: { location: '/one' }, body: '' }),
395+
);
396+
await expect(
397+
fetch(url + '/nope', { redirect: 'manual' }),
398+
responds({ status: 307, headers: { location: '/other' }, body: '' }),
399+
);
400+
});
401+
},
402+
);
403+
404+
it(
405+
'supports regular expressions in nginx-formatted mapping files',
406+
{ timeout: 3000 },
407+
async ({ getTyped }) => {
408+
const router = await buildRouter([
409+
{
410+
type: 'redirect-map',
411+
mapping: join(getTyped(NGINX_MAPS), 'regex.map'),
412+
status: 307,
413+
options: { caseSensitive: false },
414+
},
415+
FALLBACK_200,
416+
]);
417+
return withServer(router, async (url) => {
418+
await expect(
419+
fetch(url + '/one/a/end.xyz', { redirect: 'manual' }),
420+
responds({ status: 307, headers: { location: '/new/a.xyz' }, body: '' }),
421+
);
422+
await expect(
423+
fetch(url + '/one/a/END.xyz', { redirect: 'manual' }),
424+
responds({ status: 307, headers: { location: '/new/a.xyz' }, body: '' }),
425+
);
426+
await expect(
427+
fetch(url + '/nope/one/a/end.xyz', { redirect: 'manual' }),
428+
responds({ status: 200 }),
429+
);
430+
await expect(
431+
fetch(url + '/case/b/end.w', { redirect: 'manual' }),
432+
responds({ status: 307, headers: { location: '/new/b.w' }, body: '' }),
433+
);
434+
await expect(
435+
fetch(url + '/case/b/END.w', { redirect: 'manual' }),
436+
responds({ status: 200 }),
437+
);
438+
});
439+
},
440+
);
371441
});
372442

373443
it('logs requests', { timeout: 3000 }, async () => {

src/bin/man1/web-listener.1

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,17 @@ defined.
246246
Add redirects from an NGINX .map file.
247247
This format is loosely one redirect per line, with the requested URL first,
248248
then a space, then the redirect URL, and finally a semicolon.
249-
URLs can be quoted.
249+
Matching is case insensitive.
250+
URLs can be quoted, and regular expression syntax is supported by prefixing the
251+
URL with ~.
250252
Comments can be included using the # character.
251253
A full explanation can be found at
252254
.Lk https://nginx.org/en/docs/http/ngx_http_map_module.html .
253-
Note that matching is case insensitive.
255+
Note that regular expressions are parsed using native Javascript regular
256+
expression syntax rather than PCRE, as used by NGINX
257+
.Po
258+
the differences are usually not important
259+
.Pc .
254260
See
255261
.Sx REDIRECTING
256262
for the corresponding NGINX configuration.
@@ -699,25 +705,28 @@ The equivalent NGINX configuration is:
699705
.Bd -literal -offset 2n
700706
# map_hash_max_size 2048; # optionally set an explicit hashtable size
701707
map $request_uri $redirected {
702-
include ./my-redirect-file.map;
708+
include /path/to/my-redirect-file.map;
703709
}
704710

705711
server {
706-
if ($redirected == $request_uri) {
712+
if ($redirected = $request_uri) {
707713
# do not redirect if we are already at the target URL
708714
# (e.g. if redirects are being used to normalise case)
709715
set $redirected "";
710716
}
711717
if ($redirected) {
712-
return 301 $redirected;
718+
return 307 $redirected;
713719
}
720+
721+
# (rest of configuration)
714722
}
715723
.Ed
716724
.Pp
717725
An example file:
718726
.Bd -literal -offset 2n
719727
/old-url /new-url;
720728
/another-old-url "/and-its-new-url"; # a comment
729+
"~*^/regex/(.*)$" /updated/$1;
721730
.Ed
722731
.
723732
.\"======================================================================

src/bin/nginx.mts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export class Mapper {
2+
declare private readonly _norm: (v: string) => string;
3+
declare private readonly _map: Map<string, string>;
4+
declare private readonly _patterns: { _key: RegExp; _target: string }[];
5+
declare private _fallback: string;
6+
7+
constructor(caseSensitive: boolean) {
8+
this._norm = caseSensitive ? (v) => v : (v) => v.toLowerCase();
9+
this._map = new Map();
10+
this._patterns = [];
11+
this._fallback = '';
12+
}
13+
14+
add(key: string, value: string) {
15+
if (key[0] === '/') {
16+
this._map.set(this._norm(key), value);
17+
} else if (key[0] === '~') {
18+
const caseSensitive = key[1] !== '*';
19+
const pattern = key.substring(caseSensitive ? 1 : 2);
20+
this._patterns.push({ _key: new RegExp(pattern, caseSensitive ? '' : 'i'), _target: value });
21+
} else {
22+
throw new Error(`invalid URL: ${key}`);
23+
}
24+
}
25+
26+
setDefault(value: string) {
27+
this._fallback = value;
28+
}
29+
30+
get(key: string) {
31+
const literal = this._map.get(this._norm(key));
32+
if (literal !== undefined) {
33+
return literal;
34+
}
35+
for (const p of this._patterns) {
36+
p._key.lastIndex = 0;
37+
const m = p._key.exec(key);
38+
if (m) {
39+
return p._target.replaceAll(/\$(?:([0-9]+)|([a-zA-Z][a-zA-Z0-9]*))/g, (_, id, name) => {
40+
if (id) {
41+
return m[Number.parseInt(id)] ?? '';
42+
} else if (m.groups && Object.prototype.hasOwnProperty.call(m.groups, name)) {
43+
return m.groups[name] ?? '';
44+
} else {
45+
return '';
46+
}
47+
});
48+
}
49+
}
50+
return this._fallback;
51+
}
52+
}
53+
54+
export function* nginxTokenise(source: string) {
55+
let statement: { token: string; literal: boolean }[] = [];
56+
const token =
57+
/(\s+|#[^\n]*)|(;)|(?:"((?:[^"\\]+|\\.)*)")|(?:'((?:[^'\\]+|\\.)*)')|((?:[^#;\s\\"']+|\\.)+)/y;
58+
while (token.lastIndex < source.length) {
59+
const m = token.exec(source);
60+
if (!m) {
61+
throw new Error('invalid nginx syntax');
62+
}
63+
const [, separator, semicolon, dquot, squot, nquot] = m;
64+
if (separator) {
65+
continue;
66+
}
67+
if (semicolon) {
68+
yield statement;
69+
statement = [];
70+
continue;
71+
}
72+
if (nquot && !nquot.includes('\\')) {
73+
statement.push({ token: nquot, literal: true });
74+
} else {
75+
const part = dquot ?? squot ?? nquot;
76+
if (part === undefined) {
77+
throw new Error('nginx tokenisation error');
78+
}
79+
statement.push({ token: part.replaceAll(/\\(.)/g, '$1'), literal: false });
80+
}
81+
}
82+
if (statement.length) {
83+
yield statement;
84+
}
85+
}

0 commit comments

Comments
 (0)