Skip to content

Commit 5a35a03

Browse files
committed
Obey file visibility rules when compressing served files
1 parent 0c95432 commit 5a35a03

5 files changed

Lines changed: 72 additions & 36 deletions

File tree

docs/API.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,20 @@ _not_ be compressed, as they are assumed to already be compressed as part of the
558558
- `filter` [`<Function>`] a function which decides which files to attempt to compress. Called with
559559
the full file path [`<string>`] and mime type [`<string>`], returns [`<boolean>`]. **Default:**
560560
a function which rejects known image, video, audio, and font mime types.
561+
- `subDirectories` [`<boolean>`] | [`<number>`] `true` to compress files in all sub-directories
562+
recursively, `false` to only compress files directly inside the base directory. If this is set
563+
to a number, it is the depth of sub-directories which can be traversed (`0` is equivalent to
564+
`false`). **Default:** `true`.
565+
- `allowAllDotfiles` [`<boolean>`] `true` to compress all dotfiles and traverse into directories
566+
which begin with a dot. This is not typically desired, as these filenames usually denote hidden
567+
or private files. **Default:** `false`.
568+
- `allowAllTildefiles` [`<boolean>`] `true` to compress all files beginning with a tilde. This is
569+
not typically desired, as these filenames usually denote temporary files created by editors.
570+
**Default:** `false`.
571+
- `allow` [`<string[]>`][`<string>`] list of files and directories to explicitly allow compression
572+
of (which may otherwise be blocked by another rule). **Default:** `['.well-known']`.
573+
- `hide` [`<string[]>`][`<string>`] | [`<RegExp[]>`][`<RegExp>`] list of files and directories to
574+
avoid compressing. **Default:** `[]`.
561575
- Returns: [`<Promise>`] Fulfills with [`<Object[]>`][`<Object>`] containing information about the
562576
compression once all files have been processed.
563577

src/bin/compression.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export async function runCompression(servers: ConfigServer[], minCompression: nu
2020
});
2121
const filenameFilter = stringPredicate(config.match, true);
2222
const processed = await compressFilesInDir(mount.dir, config.options, {
23+
...mount.options,
2324
minCompression,
2425
filter: (file, mime) => {
2526
if (['image', 'video', 'audio', 'font'].includes(mime.split('/')[0]!)) {

src/extras/compress/offline.mts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { basename, dirname, extname, join } from 'node:path';
2-
import { readdir, readFile, writeFile, rm, utimes, stat } from 'node:fs/promises';
3-
import { Queue } from '../../util/Queue.mts';
2+
import { readFile, writeFile, rm, utimes, stat } from 'node:fs/promises';
43
import { internalMutateName, type FileNegotiationOption } from '../request/Negotiator.mts';
54
import { getMime } from '../registries/mime.mts';
65
import { internalCompressBuffer, type ContentEncoding } from './encoders.mts';
6+
import { FileFinderRules, type FileFinderOptions } from '../filesystem/FileFinder.mts';
7+
import { internalDiscoverFiles } from '../filesystem/staticFileFinder.mts';
78

89
export interface CompressionInfo {
910
/** the path to the file */
@@ -109,19 +110,15 @@ export async function compressFileOffline(
109110
export async function compressFilesInDir(
110111
dir: string,
111112
encodings: ReadonlyArray<FileNegotiationOption>,
112-
options: CompressionOptions = {},
113+
options: CompressionOptions &
114+
Pick<
115+
FileFinderOptions,
116+
'subDirectories' | 'allowAllDotfiles' | 'allowAllTildefiles' | 'hide' | 'allow'
117+
> = {},
113118
): Promise<CompressionInfo[]> {
119+
const rules = new FileFinderRules(options);
114120
const files = new Set<string>();
115-
const pathsQueue = new Queue(dir);
116-
for (const base of pathsQueue) {
117-
for (const f of await readdir(base, { withFileTypes: true, encoding: 'utf-8' })) {
118-
if (f.isDirectory()) {
119-
pathsQueue.push(join(base, f.name));
120-
} else if (f.isFile()) {
121-
files.add(join(base, f.name));
122-
}
123-
}
124-
}
121+
await internalDiscoverFiles(dir, rules, (path, name) => files.add(join(dir, ...path, name)));
125122

126123
// remove existing compressed files from the set
127124
for (const file of files) {

src/extras/compress/offline.test.mts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,24 @@ describe('compressFilesInDir', () => {
113113
const TEST_DIR = makeTestTempDir('compress-', {
114114
'compressible.txt': '.'.repeat(1000),
115115
'incompressible.txt': 'too small',
116+
'skipped.txt': '.'.repeat(1000),
116117
'poor-compression.txt': '.'.repeat(310),
117118
'already-compressed.txt': 'original',
118119
'already-compressed.txt.gz': 'compressed',
119120
'image.png': '.'.repeat(1000), // not compressed even though it could be
120121
nested: {
121122
'deep.txt': '.'.repeat(1000),
122123
},
124+
'.nope': {
125+
'deep.txt': '.'.repeat(1000),
126+
},
123127
});
124128

125129
it('compresses files which can be reduced', async ({ getTyped }) => {
126130
const dir = getTyped(TEST_DIR);
127131
const stats = await compressFilesInDir(dir, [{ value: 'gzip', file: '{file}.gz' }], {
128132
minCompression: 300,
133+
hide: ['skipped.txt'],
129134
});
130135
expect(stats).hasLength(6);
131136

@@ -155,7 +160,11 @@ describe('compressFilesInDir', () => {
155160
expect(stats5?.created).equals(1);
156161
expect(stats5!.bestSize).isLessThan(stats5!.rawSize);
157162

163+
expect(findStat(join('skipped.txt'))).isUndefined();
164+
expect(findStat(join('.nope', 'deep.txt'))).isUndefined();
165+
158166
expect((await readdir(dir)).sort()).equals([
167+
'.nope',
159168
'already-compressed.txt',
160169
'already-compressed.txt.gz',
161170
'compressible.txt',
@@ -164,6 +173,7 @@ describe('compressFilesInDir', () => {
164173
'incompressible.txt',
165174
'nested',
166175
'poor-compression.txt',
176+
'skipped.txt',
167177
]);
168178
expect((await readdir(join(dir, 'nested'))).sort()).equals(['deep.txt', 'deep.txt.gz']);
169179
});

src/extras/filesystem/staticFileFinder.mts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,12 @@ export async function staticFileFinder(
1818
const rules = new FileFinderRules(options);
1919
const precomputed = new StaticFileFinder(rules, internalTryReturn);
2020

21-
const queue = new Queue<string[]>([]);
22-
for (const path of queue) {
23-
const dirEntries = await readdir(join(root, ...path), {
24-
withFileTypes: true,
25-
encoding: 'utf-8',
26-
});
27-
const siblings = new Map(
28-
dirEntries.map((v) => [rules._normalise(v.name), join(root, ...path, v.name)]),
29-
);
30-
for (const file of dirEntries) {
31-
if (rules._checkPermitted(file.name)) {
32-
if (file.isDirectory()) {
33-
const dirPath = [...path, file.name];
34-
precomputed._addDir(dirPath);
35-
if (path.length < rules._subDirectories) {
36-
queue.push(dirPath);
37-
}
38-
} else if (file.isFile()) {
39-
precomputed._addFile(path, file.name, join(root, ...path, file.name), siblings);
40-
}
41-
}
42-
}
43-
}
21+
await internalDiscoverFiles(
22+
root,
23+
rules,
24+
(path, name, siblings) => precomputed._addFile(path, name, join(root, ...path, name), siblings),
25+
(path) => precomputed._addDir(path),
26+
);
4427
return precomputed;
4528
}
4629

@@ -142,6 +125,37 @@ export class StaticFileFinder<T> implements FileFinder {
142125
}
143126
}
144127

128+
export async function internalDiscoverFiles(
129+
root: string,
130+
rules: FileFinderRules,
131+
fileCallback: (path: string[], name: string, siblings: Map<string, string>) => void,
132+
dirCallback?: (path: string[]) => void,
133+
) {
134+
const queue = new Queue<string[]>([]);
135+
for (const path of queue) {
136+
const dirEntries = await readdir(join(root, ...path), {
137+
withFileTypes: true,
138+
encoding: 'utf-8',
139+
});
140+
const siblings = new Map(
141+
dirEntries.map((v) => [rules._normalise(v.name), join(root, ...path, v.name)]),
142+
);
143+
for (const file of dirEntries) {
144+
if (rules._checkPermitted(file.name)) {
145+
if (file.isDirectory()) {
146+
const dirPath = [...path, file.name];
147+
dirCallback?.(dirPath);
148+
if (path.length < rules._subDirectories) {
149+
queue.push(dirPath);
150+
}
151+
} else if (file.isFile()) {
152+
fileCallback(path, file.name, siblings);
153+
}
154+
}
155+
}
156+
}
157+
}
158+
145159
interface StaticFileInfo<T> {
146160
data: T | undefined;
147161
basename: string;

0 commit comments

Comments
 (0)