@aria-framework/kit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/fileSniff.js +137 -117
  2. package/package.json +24 -24
  3. package/paginate.js +124 -97
package/fileSniff.js CHANGED
@@ -1,117 +1,137 @@
1
- /**
2
- * Magic-byte file-type validation for uploads.
3
- *
4
- * multer's `file.mimetype` is the client-declared Content-Type — attacker
5
- * controlled. This module reads the actual leading bytes and checks they are
6
- * consistent with the declared MIME's container family, so an HTML file
7
- * labelled `image/png` is rejected. Office formats can't be distinguished from
8
- * each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
9
- * we validate the container family, not the exact subtype.
10
- */
11
-
12
- const fs = require('fs');
13
-
14
- /** Sniff the container/type from the first bytes. Returns a coarse tag or null. */
15
- function sniff(filePath) {
16
- let fd;
17
- try {
18
- fd = fs.openSync(filePath, 'r');
19
- const buf = Buffer.alloc(16);
20
- const n = fs.readSync(fd, buf, 0, 16, 0);
21
- const b = buf.subarray(0, n);
22
- const hex = b.toString('hex').toUpperCase();
23
-
24
- if (hex.startsWith('89504E47')) return 'image/png';
25
- if (hex.startsWith('FFD8FF')) return 'image/jpeg';
26
- if (hex.startsWith('47494638')) return 'image/gif';
27
- if (hex.startsWith('25504446')) return 'application/pdf';
28
-
29
- // RIFF container — disambiguate by the form-type at bytes 8–12.
30
- if (hex.startsWith('52494646') && n >= 12) {
31
- const form = b.subarray(8, 12).toString('ascii');
32
- if (form === 'WEBP') return 'image/webp';
33
- if (form === 'WAVE') return 'riff-wave';
34
- if (form === 'AVI ') return 'riff-avi';
35
- }
36
-
37
- // ISO-BMFF (MP4/MOV/M4A/HEIC): 'ftyp' box at bytes 4–8. We don't split the
38
- // brand — validate the container family, like we do for zip-based formats.
39
- if (n >= 8 && b.subarray(4, 8).toString('ascii') === 'ftyp') return 'isobmff';
40
- // Matroska/WebM (EBML)
41
- if (hex.startsWith('1A45DFA3')) return 'ebml';
42
- // Ogg (audio/video)
43
- if (hex.startsWith('4F676753')) return 'ogg';
44
- // MP3: ID3 tag, or an MPEG audio frame sync (0xFFEx).
45
- if (hex.startsWith('494433') || (n >= 2 && b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) return 'mp3';
46
-
47
- // ZIP container (docx/xlsx and plain zip): PK\x03\x04 / PK\x05\x06 / PK\x07\x08
48
- if (hex.startsWith('504B0304') || hex.startsWith('504B0506') || hex.startsWith('504B0708')) return 'zip';
49
- // OLE compound file (legacy doc/xls)
50
- if (hex.startsWith('D0CF11E0A1B11AE1')) return 'ole';
51
- // Other archives
52
- if (hex.startsWith('377ABCAF271C')) return '7z';
53
- if (hex.startsWith('526172211A07')) return 'rar'; // 'Rar!\x1A\x07'
54
- if (hex.startsWith('1F8B')) return 'gzip';
55
- // No binary signature: treat as text only if there are no NUL bytes.
56
- if (n > 0 && !b.includes(0)) return 'text';
57
- return null;
58
- } catch (_) {
59
- return null;
60
- } finally {
61
- if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
62
- }
63
- }
64
-
65
- // Declared MIME → the sniffed tags that are acceptable for it.
66
- const FAMILY = {
67
- 'image/png': ['image/png'],
68
- 'image/jpeg': ['image/jpeg'],
69
- 'image/gif': ['image/gif'],
70
- 'image/webp': ['image/webp'],
71
- 'application/pdf': ['application/pdf'],
72
- 'text/plain': ['text'],
73
- 'text/csv': ['text'],
74
- 'application/msword': ['ole'],
75
- 'application/vnd.ms-excel': ['ole'],
76
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['zip'],
77
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['zip'],
78
- // images (HEIC/HEIF are ISO-BMFF)
79
- 'image/heic': ['isobmff'],
80
- 'image/heif': ['isobmff'],
81
- // video
82
- 'video/mp4': ['isobmff'],
83
- 'video/quicktime': ['isobmff'],
84
- 'video/webm': ['ebml'],
85
- 'video/x-msvideo': ['riff-avi'],
86
- 'video/ogg': ['ogg'],
87
- // audio
88
- 'audio/mpeg': ['mp3'],
89
- 'audio/mp4': ['isobmff'],
90
- 'audio/x-m4a': ['isobmff'],
91
- 'audio/wav': ['riff-wave'],
92
- 'audio/x-wav': ['riff-wave'],
93
- 'audio/ogg': ['ogg'],
94
- 'audio/webm': ['ebml'],
95
- // archives
96
- 'application/zip': ['zip'],
97
- 'application/x-zip-compressed': ['zip'],
98
- 'application/x-7z-compressed': ['7z'],
99
- 'application/x-rar-compressed': ['rar'],
100
- 'application/vnd.rar': ['rar'],
101
- 'application/gzip': ['gzip']
102
- };
103
-
104
- /**
105
- * True if the file's actual content is consistent with the declared MIME.
106
- * @param {string} filePath
107
- * @param {string} declaredMime
108
- * @returns {boolean}
109
- */
110
- function matches(filePath, declaredMime) {
111
- const allowed = FAMILY[declaredMime];
112
- if (!allowed) return false;
113
- const sniffed = sniff(filePath);
114
- return !!sniffed && allowed.includes(sniffed);
115
- }
116
-
117
- module.exports = { sniff, matches };
1
+ /**
2
+ * Magic-byte file-type validation for uploads.
3
+ *
4
+ * multer's `file.mimetype` is the client-declared Content-Type — attacker
5
+ * controlled. This module reads the actual leading bytes and checks they are
6
+ * consistent with the declared MIME's container family, so an HTML file
7
+ * labelled `image/png` is rejected. Office formats can't be distinguished from
8
+ * each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
9
+ * we validate the container family, not the exact subtype.
10
+ *
11
+ * ACCEPTS A PATH OR A BUFFER. Both exist in practice: multer's disk storage gives a path, and
12
+ * its memory storage gives a buffer with no path at all — which is what a consumer writing
13
+ * documents into an encrypted database column uses. Sniffing only ever needed the first 16
14
+ * bytes, so a path-only signature meant the memory case could not be checked and simply was
15
+ * not, in the one app that stores uploads inside the database.
16
+ */
17
+
18
+ const fs = require('fs');
19
+
20
+ /** The first 16 bytes, from a path or straight from a buffer. */
21
+ function head(input) {
22
+ if (Buffer.isBuffer(input)) return input.subarray(0, 16);
23
+ let fd;
24
+ try {
25
+ fd = fs.openSync(input, 'r');
26
+ const buf = Buffer.alloc(16);
27
+ const n = fs.readSync(fd, buf, 0, 16, 0);
28
+ return buf.subarray(0, n);
29
+ } finally {
30
+ if (fd !== undefined) try { fs.closeSync(fd); } catch (_) { /* noop */ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Sniff the container/type from the first bytes. Returns a coarse tag or null.
36
+ * @param {string|Buffer} input a file path, or the bytes themselves
37
+ */
38
+ function sniff(input) {
39
+ try {
40
+ const b = head(input);
41
+ const hex = b.toString('hex').toUpperCase();
42
+
43
+ if (hex.startsWith('89504E47')) return 'image/png';
44
+ if (hex.startsWith('FFD8FF')) return 'image/jpeg';
45
+ if (hex.startsWith('47494638')) return 'image/gif';
46
+ if (hex.startsWith('25504446')) return 'application/pdf';
47
+
48
+ // RIFF container disambiguate by the form-type at bytes 8–12.
49
+ if (hex.startsWith('52494646') && b.length >= 12) {
50
+ const form = b.subarray(8, 12).toString('ascii');
51
+ if (form === 'WEBP') return 'image/webp';
52
+ if (form === 'WAVE') return 'riff-wave';
53
+ if (form === 'AVI ') return 'riff-avi';
54
+ }
55
+
56
+ // ISO-BMFF (MP4/MOV/M4A/HEIC): 'ftyp' box at bytes 4–8. We don't split the
57
+ // brand — validate the container family, like we do for zip-based formats.
58
+ if (b.length >= 8 && b.subarray(4, 8).toString('ascii') === 'ftyp') return 'isobmff';
59
+ // Matroska/WebM (EBML)
60
+ if (hex.startsWith('1A45DFA3')) return 'ebml';
61
+ // Ogg (audio/video)
62
+ if (hex.startsWith('4F676753')) return 'ogg';
63
+ // MP3: ID3 tag, or an MPEG audio frame sync (0xFFEx).
64
+ if (hex.startsWith('494433') || (b.length >= 2 && b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) return 'mp3';
65
+
66
+ // ZIP container (docx/xlsx and plain zip): PK\x03\x04 / PK\x05\x06 / PK\x07\x08
67
+ if (hex.startsWith('504B0304') || hex.startsWith('504B0506') || hex.startsWith('504B0708')) return 'zip';
68
+ // OLE compound file (legacy doc/xls)
69
+ if (hex.startsWith('D0CF11E0A1B11AE1')) return 'ole';
70
+ // Other archives
71
+ if (hex.startsWith('377ABCAF271C')) return '7z';
72
+ if (hex.startsWith('526172211A07')) return 'rar'; // 'Rar!\x1A\x07'
73
+ if (hex.startsWith('1F8B')) return 'gzip';
74
+ // No binary signature: treat as text only if there are no NUL bytes.
75
+ if (b.length > 0 && !b.includes(0)) return 'text';
76
+ return null;
77
+ } catch (_) {
78
+ // A path that cannot be opened, or bytes that cannot be read. "Unknown" rather than a
79
+ // throw: the caller's next move is to reject the file either way, and a sniffer that can
80
+ // fail loudly turns an unreadable upload into a 500.
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // Declared MIME → the sniffed tags that are acceptable for it.
86
+ const FAMILY = {
87
+ 'image/png': ['image/png'],
88
+ 'image/jpeg': ['image/jpeg'],
89
+ 'image/gif': ['image/gif'],
90
+ 'image/webp': ['image/webp'],
91
+ 'application/pdf': ['application/pdf'],
92
+ 'text/plain': ['text'],
93
+ 'text/csv': ['text'],
94
+ 'application/msword': ['ole'],
95
+ 'application/vnd.ms-excel': ['ole'],
96
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['zip'],
97
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['zip'],
98
+ // images (HEIC/HEIF are ISO-BMFF)
99
+ 'image/heic': ['isobmff'],
100
+ 'image/heif': ['isobmff'],
101
+ // video
102
+ 'video/mp4': ['isobmff'],
103
+ 'video/quicktime': ['isobmff'],
104
+ 'video/webm': ['ebml'],
105
+ 'video/x-msvideo': ['riff-avi'],
106
+ 'video/ogg': ['ogg'],
107
+ // audio
108
+ 'audio/mpeg': ['mp3'],
109
+ 'audio/mp4': ['isobmff'],
110
+ 'audio/x-m4a': ['isobmff'],
111
+ 'audio/wav': ['riff-wave'],
112
+ 'audio/x-wav': ['riff-wave'],
113
+ 'audio/ogg': ['ogg'],
114
+ 'audio/webm': ['ebml'],
115
+ // archives
116
+ 'application/zip': ['zip'],
117
+ 'application/x-zip-compressed': ['zip'],
118
+ 'application/x-7z-compressed': ['7z'],
119
+ 'application/x-rar-compressed': ['rar'],
120
+ 'application/vnd.rar': ['rar'],
121
+ 'application/gzip': ['gzip']
122
+ };
123
+
124
+ /**
125
+ * True if the file's actual content is consistent with the declared MIME.
126
+ * @param {string|Buffer} input a file path, or the bytes themselves
127
+ * @param {string} declaredMime
128
+ * @returns {boolean}
129
+ */
130
+ function matches(input, declaredMime) {
131
+ const allowed = FAMILY[declaredMime];
132
+ if (!allowed) return false;
133
+ const sniffed = sniff(input);
134
+ return !!sniffed && allowed.includes(sniffed);
135
+ }
136
+
137
+ module.exports = { sniff, matches };
package/package.json CHANGED
@@ -1,24 +1,24 @@
1
- {
2
- "name": "@aria-framework/kit",
3
- "version": "0.3.0",
4
- "description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "main": "index.js",
11
- "files": [
12
- "index.js",
13
- "safeReturnTo.js",
14
- "likeEscape.js",
15
- "isEmail.js",
16
- "fileSniff.js",
17
- "trackingId.js",
18
- "personName.js",
19
- "paginate.js"
20
- ],
21
- "scripts": {
22
- "test": "node test/smoke.js && node test/paginate.js"
23
- }
24
- }
1
+ {
2
+ "name": "@aria-framework/kit",
3
+ "version": "0.4.0",
4
+ "description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "index.js",
11
+ "files": [
12
+ "index.js",
13
+ "safeReturnTo.js",
14
+ "likeEscape.js",
15
+ "isEmail.js",
16
+ "fileSniff.js",
17
+ "trackingId.js",
18
+ "personName.js",
19
+ "paginate.js"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test/smoke.js && node test/paginate.js"
23
+ }
24
+ }
package/paginate.js CHANGED
@@ -1,97 +1,124 @@
1
- /**
2
- * Page arithmetic. Pure — no DB, no request, no opinion about how the rows are fetched.
3
- *
4
- * Every list in these apps that pages has re-derived this, and the parts that get quietly
5
- * wrong are always the same three:
6
- *
7
- * the clamp `pageSize` arrives from a query string. Unclamped, `?pageSize=100000` asks
8
- * the database for everything, which is a denial of service written by the
9
- * person browsing.
10
- * the last page deleting rows can leave you on page 9 of 4. Returning an empty list there
11
- * looks like "no records" rather than "you are past the end".
12
- * the window a list long enough to need paging is long enough that rendering every page
13
- * link is the same problem as rendering every row. 200 pages is 200 links.
14
- *
15
- * So those three are here, once.
16
- */
17
-
18
- 'use strict';
19
-
20
- const DEFAULT_SIZES = [25, 50, 100, 200];
21
-
22
- /**
23
- * @param {object} opts
24
- * @param {number|string} [opts.page=1] 1-based; garbage becomes 1
25
- * @param {number|string} [opts.pageSize] clamped to `sizes`; defaults to the first
26
- * @param {number} opts.total total matching rows
27
- * @param {number[]} [opts.sizes] the allowed page sizes, smallest first
28
- * @param {number} [opts.window=2] page links either side of the current one
29
- * @returns {{page, pageSize, pages, total, offset, limit, from, to, isFirst, isLast,
30
- * sizes, numbers, needed}}
31
- */
32
- function paginate({ page, pageSize, total, sizes = DEFAULT_SIZES, window = 2 } = {}) {
33
- const allowed = (Array.isArray(sizes) && sizes.length ? sizes : DEFAULT_SIZES)
34
- .map(Number).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
35
-
36
- const t = Math.max(0, Math.floor(Number(total) || 0));
37
-
38
- // CLAMPED TO THE ALLOWED SET, not merely bounded. A max-only check still lets `?pageSize=99`
39
- // through, and then the size dropdown shows nothing selected — the control and the data
40
- // disagree, which is the kind of small wrongness nobody reports and everybody notices.
41
- const askedSize = parseInt(pageSize, 10);
42
- const size = allowed.indexOf(askedSize) !== -1 ? askedSize : allowed[0];
43
-
44
- const pages = Math.max(1, Math.ceil(t / size));
45
-
46
- // PAST THE END COMES BACK TO THE END. Deleting rows while someone is on page 9 of 4 must
47
- // show them the last page, not an empty one that reads as "no records".
48
- const asked = parseInt(page, 10);
49
- const current = Math.min(Math.max(1, Number.isFinite(asked) ? asked : 1), pages);
50
-
51
- const offset = (current - 1) * size;
52
-
53
- return {
54
- page: current,
55
- pageSize: size,
56
- pages,
57
- total: t,
58
- offset,
59
- limit: size,
60
- // 1-based inclusive, for "showing 26–50 of 1,204". `from` is 0 on an empty list so the
61
- // caller can say "no records" rather than "showing 1–0 of 0".
62
- from: t === 0 ? 0 : offset + 1,
63
- to: Math.min(offset + size, t),
64
- isFirst: current === 1,
65
- isLast: current === pages,
66
- sizes: allowed,
67
- numbers: windowed(current, pages, window),
68
- // Whether the controls are worth showing at all: a list that fits on one page at the
69
- // smallest size does not need a size picker either.
70
- needed: t > allowed[0]
71
- };
72
- }
73
-
74
- /**
75
- * The page links to render: `[1, null, 4, 5, 6, 7, 8, null, 20]`, where null is a gap.
76
- *
77
- * First and last are always present so the ends stay reachable in one click — the case that
78
- * matters is "jump to the oldest", which is otherwise a long walk.
79
- */
80
- function windowed(current, pages, window) {
81
- const w = Math.max(0, Math.floor(Number(window) || 0));
82
- const keep = new Set([1, pages]);
83
- for (let i = current - w; i <= current + w; i++) if (i >= 1 && i <= pages) keep.add(i);
84
-
85
- const sorted = [...keep].sort((a, b) => a - b);
86
- const out = [];
87
- let prev = 0;
88
- for (const n of sorted) {
89
- // A gap of exactly one is silly — "1 … 3" is longer than "1 2 3" and harder to click.
90
- if (prev && n - prev > 1) out.push(n - prev === 2 ? prev + 1 : null);
91
- if (out[out.length - 1] !== n) out.push(n);
92
- prev = n;
93
- }
94
- return out;
95
- }
96
-
97
- module.exports = { paginate, DEFAULT_SIZES };
1
+ /**
2
+ * Page arithmetic. Pure — no DB, no request, no opinion about how the rows are fetched.
3
+ *
4
+ * Every list in these apps that pages has re-derived this, and the parts that get quietly
5
+ * wrong are always the same three:
6
+ *
7
+ * the clamp `pageSize` arrives from a query string. Unclamped, `?pageSize=100000` asks
8
+ * the database for everything, which is a denial of service written by the
9
+ * person browsing.
10
+ * the last page deleting rows can leave you on page 9 of 4. Returning an empty list there
11
+ * looks like "no records" rather than "you are past the end".
12
+ * the window a list long enough to need paging is long enough that rendering every page
13
+ * link is the same problem as rendering every row. 200 pages is 200 links.
14
+ *
15
+ * So those three are here, once.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const DEFAULT_SIZES = [25, 50, 100, 200];
21
+
22
+ /**
23
+ * A strict integer, or null.
24
+ *
25
+ * parseInt is wrong for query-string input in two ways that both fail QUIETLY: it truncates
26
+ * ('12abc' -> 12, '1e3' -> 1), so garbage becomes a different page rather than a rejected one;
27
+ * and given the array Express produces for a repeated parameter it silently reads the FIRST
28
+ * element, so `?pageSize=200&pageSize=25` keeps 200 and the picker appears not to work.
29
+ */
30
+ function intOrNull(v) {
31
+ if (Array.isArray(v)) v = v[v.length - 1]; // a repeat means "the latest wins"
32
+ if (typeof v === 'number') return Number.isInteger(v) ? v : null;
33
+ if (typeof v !== 'string' || !/^-?\d+$/.test(v.trim())) return null;
34
+ const n = Number(v.trim());
35
+ return Number.isInteger(n) ? n : null;
36
+ }
37
+
38
+ /**
39
+ * @param {object} opts
40
+ * @param {number|string} [opts.page=1] 1-based; garbage becomes 1
41
+ * @param {number|string} [opts.pageSize] clamped to `sizes`; defaults to the first
42
+ * @param {number} opts.total total matching rows
43
+ * @param {number[]} [opts.sizes] the allowed page sizes, smallest first
44
+ * @param {number} [opts.window=2] page links either side of the current one
45
+ * @returns {{page, pageSize, pages, total, offset, limit, from, to, isFirst, isLast,
46
+ * sizes, numbers, needed}}
47
+ */
48
+ function paginate({ page, pageSize, total, sizes = DEFAULT_SIZES, window = 2 } = {}) {
49
+ const allowed = (Array.isArray(sizes) && sizes.length ? sizes : DEFAULT_SIZES)
50
+ .map(Number).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
51
+
52
+ const t = Math.max(0, Math.floor(Number(total) || 0));
53
+
54
+ // CLAMPED TO THE ALLOWED SET, not merely bounded. A max-only check still lets `?pageSize=99`
55
+ // through, and then the size dropdown shows nothing selected — the control and the data
56
+ // disagree, which is the kind of small wrongness nobody reports and everybody notices.
57
+ // `intOrNull`, not parseInt: parseInt('12abc') is 12 and parseInt('1e3') is 1, so garbage
58
+ // becomes a DIFFERENT page rather than a rejected one. And a repeated query parameter
59
+ // arrives as an array — Express gives ['200','25'] for `?pageSize=200&pageSize=25` — where
60
+ // parseInt returns 200, silently ignoring the newer choice.
61
+ const size = allowed.indexOf(intOrNull(pageSize)) !== -1 ? intOrNull(pageSize) : allowed[0];
62
+
63
+ const pages = Math.max(1, Math.ceil(t / size));
64
+
65
+ // PAST THE END COMES BACK TO THE END. Deleting rows while someone is on page 9 of 4 must
66
+ // show them the last page, not an empty one that reads as "no records".
67
+ const asked = intOrNull(page);
68
+ const current = Math.min(Math.max(1, asked === null ? 1 : asked), pages);
69
+
70
+ const offset = (current - 1) * size;
71
+
72
+ return {
73
+ page: current,
74
+ pageSize: size,
75
+ pages,
76
+ total: t,
77
+ offset,
78
+ limit: size,
79
+ // 1-based inclusive, for "showing 26–50 of 1,204". `from` is 0 on an empty list so the
80
+ // caller can say "no records" rather than "showing 1–0 of 0".
81
+ from: t === 0 ? 0 : offset + 1,
82
+ to: Math.min(offset + size, t),
83
+ isFirst: current === 1,
84
+ isLast: current === pages,
85
+ sizes: allowed,
86
+ numbers: windowed(current, pages, window),
87
+ // Worth showing the controls at all? Two separate questions, because they have two
88
+ // different answers and conflating them renders a pager whose only content is one
89
+ // disabled page number.
90
+ //
91
+ // needed there is more than one page AT THIS SIZE — i.e. anything to navigate
92
+ // resizable the list is longer than the smallest size, so the picker can still do
93
+ // something even when this page shows everything (200-per-page over 100 rows)
94
+ needed: pages > 1,
95
+ resizable: t > allowed[0]
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The page links to render: `[1, null, 4, 5, 6, 7, 8, null, 20]`, where null is a gap.
101
+ *
102
+ * First and last are always present so the ends stay reachable in one click — the case that
103
+ * matters is "jump to the oldest", which is otherwise a long walk.
104
+ */
105
+ function windowed(current, pages, window) {
106
+ const w = Math.max(0, Math.floor(Number(window) || 0));
107
+ const keep = new Set([1, pages]);
108
+ for (let i = current - w; i <= current + w; i++) if (i >= 1 && i <= pages) keep.add(i);
109
+
110
+ const sorted = [...keep].sort((a, b) => a - b);
111
+ const out = [];
112
+ let prev = 0;
113
+ for (const n of sorted) {
114
+ // A gap of exactly one is silly — "1 … 3" is longer than "1 2 3" and harder to click.
115
+ if (prev && n - prev > 1) out.push(n - prev === 2 ? prev + 1 : null);
116
+ // No duplicate check needed: `sorted` comes from a Set, and the only other push is
117
+ // `prev + 1` in the gap-of-two branch, where n === prev + 2.
118
+ out.push(n);
119
+ prev = n;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ module.exports = { paginate, DEFAULT_SIZES };