Skip to content

Commit 02554d8

Browse files
committed
Allow zip contents in all places where files/directories are accepted in the CLI
1 parent 9f81b8a commit 02554d8

16 files changed

Lines changed: 307 additions & 71 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,6 @@ and
9090
Note that this library does not implement rate limiting of any kind, so if you have an endpoint
9191
which is vulnerable to rapid requests (e.g. a password checking endpoint), you should set up your
9292
own rate limiting or use a reverse proxy such as NGINX and configure rate limiting there.
93+
94+
The CLI offers several ways of running custom code (such as background tasks and custom handlers),
95+
so should never be launched with an untrusted configuration or bundle.

docs/API.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,14 +2540,14 @@ file. Can be combined with [`registerMime`] to register the result.
25402540

25412541
[`readZip`]: #readzippath
25422542

2543-
- `path` [`<string>`] path to the zip file to read.
2543+
- `path` [`<string>`] path to the zip archive to read.
25442544
- Returns: [`<Promise>`] Fulfills with [`<ZipDirectory>`].
25452545

2546-
Reads the structure of a `.zip` file and returns the root directory. This can be used to navigate to
2546+
Reads the structure of a zip archive and returns the root directory. This can be used to navigate to
25472547
and open any file in the zip.
25482548

25492549
Compressed files in the zip are represented by "virtual" sibling files with `.deflate` suffixes. For
2550-
example, a zip containing:
2550+
example, a zip archive containing:
25512551

25522552
- `foo/file1.txt` (compressed)
25532553
- `foo/file2.txt` (stored)
@@ -3874,7 +3874,8 @@ connection can be reused _without_ needing to consume the body if `willSendBody`
38743874
[`zipFileFinder`]: #zipfilefinderzipdirectory-options
38753875
38763876
- `zipDirectory` [`<ZipDirectory>`] the base directory to serve files from. Only content within this
3877-
directory (or sub-directories) in the zip be served. See [`readZip`] and [`ZipDirectory.find`].
3877+
directory (or sub-directories) in the zip archive will be served. See [`readZip`] and
3878+
[`ZipDirectory.find`].
38783879
- `options` [`<Object>`] A set of options controlling how files are matched, and which files are
38793880
visible
38803881
- `subDirectories` [`<boolean>`] | [`<number>`] `true` to allow access to all sub-directories,
@@ -3913,7 +3914,7 @@ Example usage: [Serving zip assets]
39133914
39143915
- Extends: [`<ZipNode>`]
39153916
3916-
A representation of a directory in a zip file.
3917+
A representation of a directory in a zip archive.
39173918
39183919
#### `zipdirectory.allFiles()`
39193920
@@ -3944,7 +3945,7 @@ Returns the child node at the requested path, or returns `undefined` if the path
39443945
39453946
- Extends: [`<ZipNode>`]
39463947
3947-
A representation of a file in a zip file.
3948+
A representation of a file in a zip archive.
39483949
39493950
#### `zipfile.virtual`
39503951
@@ -3995,7 +3996,7 @@ Ensure the `FileHandle` is always `close`d when no-longer required.
39953996
39963997
[`<ZipNode>`]: #zipnode
39973998
3998-
A representation of a file ([`<ZipFile>`]) or directory ([`<ZipDirectory>`]) in a zip file.
3999+
A representation of a file ([`<ZipFile>`]) or directory ([`<ZipDirectory>`]) in a zip archive.
39994000
40004001
#### `zipnode.isDirectory`
40014002
@@ -4309,7 +4310,7 @@ Reference: [`staticJSON`], [`<Router>`]
43094310

43104311
[Serving zip assets]: #serving-zip-assets
43114312

4312-
Example of serving static content directly from a `.zip` file:
4313+
Example of serving static content directly from a zip archive:
43134314

43144315
```js
43154316
import {
@@ -4335,7 +4336,7 @@ router.use(
43354336
Reference: [`assetServer`], [`negotiateEncoding`], [`Negotiator`], [`readZip`], [`<Router>`],
43364337
[`zipFileFinder`]
43374338

4338-
The recommended way to generate a zip file for this purpose on MacOS and Unix is:
4339+
The recommended way to generate a zip archive for this purpose on MacOS and Unix is:
43394340

43404341
```sh
43414342
zip -9 -X -r -n .gz:.png:.jpg:.jpeg content.zip index.html style.css [...]

docs/CLI.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This tool supports:
1616
- [running multiple servers simultaneously](#run-multiple-servers)
1717
- [running background tasks](#run-background-tasks)
1818
- [registering custom Javascript handlers](#custom-handlers)
19+
- [loading config and serving files directly from zips](#using-zips)
1920

2021
Simple servers can be configured via CLI flags. Complex servers can be configured via JSON.
2122

@@ -525,3 +526,51 @@ Notes:
525526
is possible to add versioning strings to imported paths (e.g. `./my-handler.mjs?v1`) to work
526527
around this, but doing so will lead to ever-increasing memory usage as old scripts remain in
527528
memory, and is not suitable for production deployments or long-lived development servers.
529+
530+
### Using Zips
531+
532+
All configuration files, custom handler scripts, and served directories can be specified as paths
533+
within a zip archive, using the form:
534+
535+
```
536+
/path/to/file.zip/path/within/zip
537+
```
538+
539+
If a config file is in a zip archive, any paths it contains will be relative to its location in the
540+
zip. This can be used to produce single-file "bundles" for simple services. For example, a zip named
541+
`bundle.zip` containing:
542+
543+
- `config.json`:
544+
545+
```json
546+
{
547+
"servers": [
548+
{
549+
"port": 8080,
550+
"mount": [
551+
{ "type": "custom", "path": "/custom", "import": "./custom-handler.mjs" },
552+
{ "type": "files", "path": "/", "dir": "static" }
553+
]
554+
}
555+
],
556+
"mime": ["file://apache.types"]
557+
}
558+
```
559+
560+
- [`apache.types`](#add-mime-types-from-apache-types-file)
561+
- [`custom-handler.mjs`](#custom-handlers)
562+
- `static`
563+
- `index.html`
564+
565+
Could be loaded with:
566+
567+
```sh
568+
npx web-listener --config-file ./bundle.zip/config.json
569+
```
570+
571+
When performing path resolution, the zip is interpreted as a regular directory, so config files
572+
inside zip archives can still reference files outside the zip by navigating to them using `..`, or
573+
using absolute paths.
574+
575+
Note that as with all CLI configuration, you should not use untrusted bundles, as they have the
576+
ability to access files and execute arbitrary code using the permissions of the current user.

package/cli.test.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,41 @@ describe('cli', () => {
168168
expect(p.errors).isEmpty();
169169
});
170170

171+
it(
172+
'accepts compressed configuration files',
173+
{ timeout: 3000 },
174+
async ({ [TEARDOWN]: teardown }) => {
175+
const [port] = await findAvailablePorts(1);
176+
177+
const p = spawnProcess(
178+
join(...binDir, 'web-listener'),
179+
['-c', join('cli', 'bundle.zip', 'sample-config.json'), '--port', String(port)],
180+
{ env: { NO_COLOR: '1' } },
181+
);
182+
teardown(p.close);
183+
if (!(await awaitLine(p.stderr, 'all servers ready'))) {
184+
fail('failed to start server');
185+
}
186+
187+
const resFile = await fetch(`http://localhost:${port}/file.txt`);
188+
expect(resFile.status).equals(200);
189+
expect(resFile.headers.get('content-type')).equals('text/bundled-apache; charset=utf-8');
190+
expect(await resFile.text()).equals('Bundled content\n');
191+
192+
const resFixture = await fetch(`http://localhost:${port}/config.json`);
193+
expect(resFixture.status).equals(200);
194+
expect(await resFixture.text()).equals('{"env":"bundled"}');
195+
196+
const resRedirect = await fetch(`http://localhost:${port}/request`, { redirect: 'manual' });
197+
expect(resRedirect.status).equals(307);
198+
expect(resRedirect.headers.get('location')).equals('/bundled-other');
199+
200+
const resCustom = await fetch(`http://localhost:${port}/custom`);
201+
expect(resCustom.status).equals(200);
202+
expect(await resCustom.text()).equals('bundled custom response');
203+
},
204+
);
205+
171206
const TEARDOWN = beforeEach(({ setParameter }) => {
172207
const tasks = [];
173208
setParameter((fn) => tasks.push(fn));

package/cli/bundle.zip

916 Bytes
Binary file not shown.

src/bin/ServerManager.mts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream';
44
import { findCause, HTTPError, WebListener, type ListenOptions } from '../index.mts';
55
import type { ConfigServer, ConfigServerOptions, ConfigBackgroundTask } from './config/types.mts';
66
import { buildRouter } from './routes/buildRouter.mts';
7+
import { clearZipCache } from './zipCache.mts';
78
import type { Logger, AddColour } from './log.mts';
89
import { TransientError } from './TransientError.mts';
910

@@ -123,7 +124,10 @@ export class ServerManager {
123124
if (this._stopping) {
124125
this._shutdown();
125126
} else if (doRetry) {
126-
this._autoRetry = setTimeout(() => this.set(servers, backgroundTasks, errorHandler), 1000);
127+
this._autoRetry = setTimeout(() => {
128+
clearZipCache();
129+
this.set(servers, backgroundTasks, errorHandler);
130+
}, 1000);
127131
} else if (this._servers.size) {
128132
this._log(1, 'all servers ready');
129133
} else {

src/bin/config/loader.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { readFile } from 'node:fs/promises';
21
import type { FallbackOptions, FileNegotiation, FileNegotiationOption } from '../../index.mts';
2+
import { readAnyFile } from '../zipCache.mts';
33
import type { Mapper } from './schema.mts';
44
import type { Config, ConfigHeaders, ConfigMount } from './types.mts';
55

@@ -161,7 +161,7 @@ export async function loadConfig(
161161

162162
let config: Config;
163163
if (file) {
164-
config = parser(JSON.parse(await readFile(file, 'utf-8')), { file, path: '' });
164+
config = parser(JSON.parse(await readAnyFile(file)), { file, path: '' });
165165
} else if (json) {
166166
config = parser(JSON.parse(json), { file: '', path: '' });
167167
} else {

src/bin/man1/web-listener.1

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,16 @@ See
114114
below for details on the format.
115115
When JSON is provided this way, paths in the document are resolved relative to
116116
the file, not the current working directory.
117+
This can also be the path to a file inside a .zip archive
118+
.Po "e.g. "
119+
.Qq "/path/to/file.zip/config.json"
120+
.Pc .
117121
Once running, the configuration can be reloaded by sending SIGHUP, or by
118122
providing a newline to stdin.
119123
.
120124
.It Fl d Ar directory , Fl -dir Ar directory
121125
The directory to serve files from, relative to the current working directory.
122-
This can also be the path to a .zip file, or a path within a .zip file
126+
This can also be the path to a .zip archive, or a path within a .zip archive
123127
.Po "e.g. "
124128
.Qq "/path/to/file.zip/path/within/zip"
125129
.Pc .

src/bin/mime.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { readFile } from 'node:fs/promises';
21
import { decompressMime, readMimeTypes, registerMime, resetMime } from '../index.mts';
32
import type { ConfigMime } from './config/types.mts';
3+
import { readAnyFile } from './zipCache.mts';
44

55
export async function loadMime(mime: ConfigMime | ConfigMime[]) {
66
const newMimes: Map<string, string>[] = [];
77
for (const item of Array.isArray(mime) ? mime : [mime]) {
88
if (typeof item !== 'string') {
99
newMimes.push(new Map(Object.entries(item)));
1010
} else if (item.startsWith('file://')) {
11-
newMimes.push(readMimeTypes(await readFile(item.substring(7), 'utf-8')));
11+
newMimes.push(readMimeTypes(await readAnyFile(item.substring(7))));
1212
} else {
1313
newMimes.push(decompressMime(item));
1414
}

src/bin/routes/anyFileFinder.mts

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import { stat } from 'node:fs/promises';
2-
import { join, sep } from 'node:path';
32
import {
43
dynamicFileFinder,
54
negotiateEncoding,
65
Negotiator,
7-
readZip,
86
staticFileFinder,
97
zipFileFinder,
108
type FileFinder,
119
type FileServerOptions,
1210
} from '../../index.mts';
1311
import { TransientError } from '../TransientError.mts';
12+
import { readZipPath } from '../zipCache.mts';
1413

1514
export async function anyFileFinder(path: string, options: FileServerOptions): Promise<FileFinder> {
1615
const direct = await stat(path).catch(() => null);
@@ -22,35 +21,18 @@ export async function anyFileFinder(path: string, options: FileServerOptions): P
2221
}
2322
}
2423

25-
const parts = path.split(sep);
26-
if (parts[parts.length - 1] === '') {
27-
parts.pop();
24+
const zip = await readZipPath(path, false);
25+
if (!zip) {
26+
throw new TransientError(`content to serve not found at ${path}`);
2827
}
29-
if (!parts[0]) {
30-
parts.shift();
31-
if (parts.length > 0) {
32-
parts[0] = sep + parts[0];
33-
}
28+
29+
const zipDir = zip.root.find(zip.remaining);
30+
if (!zipDir?.isDirectory) {
31+
throw new Error(`/${zip.remaining.join('/')} in ${zip.path} is not a directory`);
3432
}
35-
for (let i = parts.length; i > 0; --i) {
36-
const filePath = join(...parts.slice(0, i));
37-
const stats = await stat(filePath).catch(() => null);
38-
if (!stats) {
39-
continue;
40-
}
41-
if (!stats.isFile()) {
42-
break;
43-
}
44-
const zipRoot = await readZip(filePath);
45-
const zipDir = zipRoot.find(parts.slice(i));
46-
if (!zipDir?.isDirectory) {
47-
throw new Error(`${parts.slice(i).join('/')} in ${filePath} is not a directory`);
48-
}
49-
const adjustedOptions = options;
50-
if (!adjustedOptions.negotiator) {
51-
adjustedOptions.negotiator = new Negotiator([negotiateEncoding(['deflate'])]);
52-
}
53-
return zipFileFinder(zipDir, adjustedOptions);
33+
const adjustedOptions = options;
34+
if (!adjustedOptions.negotiator) {
35+
adjustedOptions.negotiator = new Negotiator([negotiateEncoding(['deflate'])]);
5436
}
55-
throw new TransientError(`content to serve not found at ${path}`);
37+
return zipFileFinder(zipDir, adjustedOptions);
5638
}

0 commit comments

Comments
 (0)