Skip to content
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

StaticFiles and StaticFileResponder improvements #448

Merged
merged 16 commits into from
Jan 14, 2025
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Breaking changes:
* None.

Other notable changes:
* `webapp-builtins`:
* `StaticFileResponder`: Added `indexFile` configuration option.
* `webapp-util`:
* New class `StaticFileResponder`, extracted from
`webapp-builtins.StaticFiles`.
Expand Down
14 changes: 8 additions & 6 deletions doc/configuration/4-built-in-applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,17 @@ reasonable demand:
An application which serves static files from a local directory. This
application accepts the following configuration bindings:

* `etag` — ETag-generating options. If present and not `null`, the
response comes with an `ETag` header. See
[ETag Configuration](#etag-configuration-etag) for details.
* `cacheControl` — `cache-control` header definition. If present and not
`null`, every cacheable response comes with the specified header. See
[Cache control configuration](#cache-control-configuration-cacheControl) for
details.
* `etag` — ETag-generating options. If present and not `null`, the
response comes with an `ETag` header. See
[ETag Configuration](#etag-configuration-etag) for details.
* `indexFile` — Name or list of names to search for in response to a
directory request, or `null` (or empty array) to respond to directory requests
as not-found. When there is more than one name, the first one in listed order
to be found is the one that is used. Default is `index.html`.
* `notFoundPath` — Optional filesystem path to the file to serve when a
file/path is not found. The indicated file will get sent back along with a
`404` ("Not Found") status code.
Expand All @@ -478,6 +482,7 @@ const applications = [
{
name: 'mySite',
class: StaticFiles,
indexFile: ['index.html', 'index.txt'],
siteDirectory: '/path/to/site',
notFoundPath: '/path/to/404.html'
}
Expand Down Expand Up @@ -505,9 +510,6 @@ reasonable demand:
* Directory responses:
* "Naked" directory paths (i.e. ones that do not end with a slash) are
redirected to the same path with a final slash appended.
* Directory paths are responded to with the contents of a file called
`index.html` in that directory, if it exists. The index file name is not
configurable.
* These "odd" URL paths all cause not-found responses:
* Ones with a `..` that would "back out" of the site directory.
* Ones with an _encoded_ slash in them, that is to say literally `%2F`. (It is
Expand Down
2 changes: 2 additions & 0 deletions etc/example-setup/config/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ const applications = [
class: StaticFiles,
siteDirectory: filePath('../site'),
notFoundPath: filePath('../site-extra/not-found.html'),
indexFile: ['index.html', 'index.txt'],
cacheControl: { public: true, maxAge: '5 min' },
etag: { dataOnly: true, hashLength: 20 }
},
Expand Down Expand Up @@ -337,6 +338,7 @@ const config = {
'/application/*': false,
'/application/mySeries/*': true,
'/application/mySite/*': true,
// '/application/myStaticFun/*': true,
'/application/myRedirector/*': true
}
};
Expand Down
1 change: 1 addition & 0 deletions etc/example-setup/site/avec-index-txt/index.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
I am an index.txt file!
1 change: 1 addition & 0 deletions etc/example-setup/site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<h1>Hello!</h1>
<ul>
<li><a href="avec-index">avec-index/</a></li>
<li><a href="avec-index">avec-index-txt/</a></li>
<li>florp/
<ul>
<li><a href="florp/boop.html">boop.html</a></li>
Expand Down
1 change: 1 addition & 0 deletions etc/example-setup/site/sans-index/nope.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This directory does not have an index file.
153 changes: 52 additions & 101 deletions src/webapp-builtins/export/StaticFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import fs from 'node:fs/promises';

import { Paths, Statter } from '@this/fs-util';
import { DispatchInfo, FullResponse, HttpUtil, MimeTypes, StatusResponse }
import { FullResponse, HttpUtil, MimeTypes, StatusResponse }
from '@this/net-util';
import { BaseApplication } from '@this/webapp-core';
import { StaticFileResponder } from '@this/webapp-util';
Expand All @@ -16,11 +16,11 @@ import { StaticFileResponder } from '@this/webapp-util';
*/
export class StaticFiles extends BaseApplication {
/**
* Handler for "found file" cases.
* Configuration to pass to the {@link StaticFileResponder} constructor.
*
* @type {StaticFileResponder}
* @type {object}
*/
#foundResponder;
#responderConfig;

/**
* Path to the file to serve for a not-found result, or `null` if not-found
Expand All @@ -42,7 +42,15 @@ export class StaticFiles extends BaseApplication {
*
* @type {?string}
*/
#cacheControl = null;
#cacheControl;

/**
* "Responder" that does most of the actual work of this class, or `null` if
* not yet set up.
*
* @type {?StaticFileResponder}
*/
#responder = null;

/**
* Not-found response to issue, or `null` if either not yet calculated or if
Expand All @@ -69,44 +77,33 @@ export class StaticFiles extends BaseApplication {
constructor(rawConfig) {
super(rawConfig);

const { cacheControl, etag, notFoundPath, siteDirectory } = this.config;
const { cacheControl, etag, indexFile, notFoundPath, siteDirectory } =
this.config;

this.#cacheControl = cacheControl;
this.#foundResponder = new StaticFileResponder({ cacheControl, etag });
this.#notFoundPath = notFoundPath;
this.#siteDirectory = siteDirectory;
this.#responderConfig = {
baseDirectory: siteDirectory,
cacheControl,
etag,
indexFile
};

this.#cacheControl = cacheControl;
this.#notFoundPath = notFoundPath;
this.#siteDirectory = siteDirectory;
}

/** @override */
async _impl_handleRequest(request, dispatch) {
if (!request.isGetOrHead()) {
return StatusResponse.FORBIDDEN;
}

const resolved = await this.#resolvePath(dispatch);
const response =
await this.#responder.handleRequest(request, dispatch);

if (!resolved) {
return this.#notFound();
}

const { path, stats, redirect } = resolved;

if (redirect) {
const response = FullResponse.makeRedirect(redirect, 308);

if (this.#cacheControl) {
response.cacheControl = this.#cacheControl;
}

return await response;
} else if (path) {
return this.#foundResponder.makeResponse(request, path, stats);
if (response) {
return response;
} if (!request.isGetOrHead()) {
return StatusResponse.FORBIDDEN;
} else {
/* c8 ignore start */
// Shouldn't happen. If we get here, it's a bug in this class.
throw new Error('Shouldn\'t happen.');
return this.#notFound();
}
/* c8 ignore stop */
}

/** @override */
Expand All @@ -127,6 +124,11 @@ export class StaticFiles extends BaseApplication {
await this.#notFound();
}

this.#responder = new StaticFileResponder({
...this.#responderConfig,
logger: this.logger
});

await super._impl_start();
}

Expand Down Expand Up @@ -184,71 +186,6 @@ export class StaticFiles extends BaseApplication {
return response;
}

/**
* Figures out the absolute path to serve for the given request path.
*
* @param {DispatchInfo} dispatch Dispatch info containing the path to serve.
* @returns {{path: string, stats: fs.Stats}|{redirect: string}|null} The
* absolute path and stat info of the file to serve, the (absolute or
* relative) URL to redirect to, or `null` if given invalid input or the
* indicated path is not found.
*/
async #resolvePath(dispatch) {
const decoded = StaticFileResponder.decodePath(dispatch.extra.path);

if (!decoded) {
return null;
}

const { path, isDirectory } = decoded;

// The conditional guarantees that the `fullPath` does not end with a slash,
// which makes the code below a bit simpler (because we care about only
// producing canonicalized paths that have no double slashes).
const fullPath = (path === '')
? this.#siteDirectory
: `${this.#siteDirectory}/${path}`;
this.logger?.fullPath(fullPath);

try {
const stats = await Statter.statOrNull(fullPath);
if (stats === null) {
this.logger?.notFound(fullPath);
return null;
} else if (stats.isDirectory()) {
if (!isDirectory) {
// Redirect from non-ending-slash directory path. As a special case,
// `path === ''` happens when the mount point was requested directly,
// without a final slash. So we need to look at the base to figure out
// what to redirect to.
const source = (path === '') ? dispatch.base.path : dispatch.extra.path;
return { redirect: `${source[source.length - 1]}/` };
} else {
// It's a proper directory reference. Look for the index file.
const indexPath = `${fullPath}/index.html`;
const indexStats = await Statter.statOrNull(indexPath, true);
if (indexStats === null) {
this.logger?.indexNotFound(indexPath);
return null;
} else if (indexStats.isDirectory()) {
// Weird case, to be clear!
this.logger?.indexIsDirectory(indexPath);
return null;
}
return { path: indexPath, stats: indexStats };
}
} else if (isDirectory) {
// Non-directory file requested as if it is a directory (that is, with a
// final slash). Not accepted per class contract.
return null;
}
return { path: fullPath, stats };
} catch (e) {
this.logger?.statError(fullPath, e);
return null;
}
}


//
// Static members
Expand Down Expand Up @@ -284,6 +221,20 @@ export class StaticFiles extends BaseApplication {
return StaticFileResponder.checkEtag(value);
}

/**
* A file name to look for, or a list of file names to look for in order,
* to use when responding to a request for a directory. If `null` or an
* empty array, plain directory requests get a not-found response. Names
* must not contain any slash (`/`) characters.
*
* @param {?string|string[]} [value] Proposed configuration value. Default
* `index.html`.
* @returns {?string[]} Accepted configuration value.
*/
_config_indexFile(value = 'index.html') {
return StaticFileResponder.checkIndexFile(value);
}

/**
* Absolute path to the file to serve for a not-found result, or `null` if
* not-found handling shouldn't be done.
Expand All @@ -304,7 +255,7 @@ export class StaticFiles extends BaseApplication {
* @returns {string} Accepted configuration value.
*/
_config_siteDirectory(value) {
return Paths.checkAbsolutePath(value);
return StaticFileResponder.checkBaseDirectory(value);
}
};
}
Expand Down
60 changes: 59 additions & 1 deletion src/webapp-builtins/tests/StaticFiles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('constructor', () => {
expect(() => new StaticFiles({
cacheControl: { maxAge: '5 min' },
etag: true,
indexFile: ['blorp.html', 'bleep.txt'],
notFoundPath: '/blip/blop/bloop.html',
siteDirectory: '/florp/fleep'
})).not.toThrow();
Expand Down Expand Up @@ -87,6 +88,53 @@ describe('constructor', () => {
})).toThrow();
});

test('accepts `indexFile: null`', () => {
expect(() => new StaticFiles({
indexFile: null,
siteDirectory: '/florp/fleep'
})).not.toThrow();
});

test('accepts `indexFile: []`', () => {
expect(() => new StaticFiles({
indexFile: [],
siteDirectory: '/florp/fleep'
})).not.toThrow();
});

test('accepts `indexFile` with a single string', () => {
expect(() => new StaticFiles({
indexFile: 'bonk.html',
siteDirectory: '/florp/fleep'
})).not.toThrow();
});

test('accepts `indexFile` with an array of strings', () => {
expect(() => new StaticFiles({
indexFile: ['bonk.html', 'blorp', 'zamboni.txt'],
siteDirectory: '/florp/fleep'
})).not.toThrow();
});

test('rejects `indexFile` with a string containing a slash', () => {
expect(() => new StaticFiles({
indexFile: ['x.txt', 'zip/zap.txt'],
siteDirectory: '/florp/fleep'
})).toThrow();

expect(() => new StaticFiles({
indexFile: 'beep/boop.html',
siteDirectory: '/florp/fleep'
})).toThrow();
});

test('rejects an invalid typed `indexFile`', () => {
expect(() => new StaticFiles({
indexFile: { eep: 'oop' },
siteDirectory: '/florp/fleep'
})).toThrow();
});

test('rejects a non-absolute `siteDirectory`', () => {
expect(() => new StaticFiles({
siteDirectory: 'florp/flop'
Expand Down Expand Up @@ -239,7 +287,7 @@ describe('_impl_handleRequest()', () => {
expect(result.headers.get('location')).toBe(expectedLoc);
});

test('includes a `cache-control` header in responses if so configured', async () => {
test('includes a `cache-control` header in file responses if so configured', async () => {
const sf = await makeInstance({ cacheControl: 'florp=123' });
const request = RequestUtil.makeGet('/some-file.txt');
const result = await sf.handleRequest(request, new DispatchInfo(PathKey.EMPTY, request.pathname));
Expand All @@ -249,6 +297,16 @@ describe('_impl_handleRequest()', () => {
expect(result.cacheControl).toBe('florp=123');
});

test('includes a `cache-control` header in redirect responses if so configured', async () => {
const sf = await makeInstance({ cacheControl: 'florp=123' });
const request = RequestUtil.makeGet('/subdir2');
const result = await sf.handleRequest(request, new DispatchInfo(PathKey.EMPTY, request.pathname));

expect(result).toBeInstanceOf(FullResponse);
expect(result.status).toBe(308);
expect(result.cacheControl).toBe('florp=123');
});

test('includes an `etag` header in responses if so configured', async () => {
const sf = await makeInstance({ etag: true });
const request = RequestUtil.makeGet('/some-file.txt');
Expand Down
Loading
Loading