@aria-framework/kit 0.5.0 → 0.6.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.
- package/asyncHandler.js +24 -0
- package/dbTime.js +98 -0
- package/fileSniff.js +210 -210
- package/index.js +39 -31
- package/package.json +5 -3
- package/paginate.js +124 -124
package/asyncHandler.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap an async Express route handler so a rejected promise reaches the error handler.
|
|
3
|
+
*
|
|
4
|
+
* Express 4 does not await a handler, so a rejection inside one becomes an UNHANDLED REJECTION
|
|
5
|
+
* rather than a 500: the request hangs until the client gives up, and the process may exit if
|
|
6
|
+
* the app treats unhandled rejections as fatal — which both consumers do. Neither symptom
|
|
7
|
+
* points at the route that caused it.
|
|
8
|
+
*
|
|
9
|
+
* Five identical lines in both apps, differing only in the wording of the comment above them.
|
|
10
|
+
* Trivial, and free to stop writing twice.
|
|
11
|
+
*
|
|
12
|
+
* router.get('/x', asyncHandler(async (req, res) => { ... }))
|
|
13
|
+
*
|
|
14
|
+
* Express 5 handles this natively. Kept anyway: it is a no-op there, and removing it would mean
|
|
15
|
+
* editing every route the day an app upgrades.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
module.exports = function asyncHandler(fn) {
|
|
21
|
+
return function (req, res, next) {
|
|
22
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
23
|
+
};
|
|
24
|
+
};
|
package/dbTime.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a SQLite timestamp, and seeing it in somebody's timezone.
|
|
3
|
+
*
|
|
4
|
+
* THE THREE PRIMITIVES, AND NOT THE FORMATTERS. Both consuming apps have a `formatDate`, and
|
|
5
|
+
* they are genuinely different functions: one renders DD/MM/YYYY always, the other assembles by
|
|
6
|
+
* locale because it invoices across countries where the date order differs. Promoting either
|
|
7
|
+
* would have imposed a product decision on the other app. What IS the same is everything
|
|
8
|
+
* underneath — parsing a stored timestamp, and asking what wall-clock it was in a zone — and
|
|
9
|
+
* that is where the two copies had already drifted.
|
|
10
|
+
*
|
|
11
|
+
* THE DRIFT THIS CLOSES: one app cached the `Intl.DateTimeFormat` per timezone and the other
|
|
12
|
+
* constructed a new one on every call. Construction is milliseconds, and a list page calls this
|
|
13
|
+
* once per row per column, so the uncached copy quietly cost real time on exactly the pages that
|
|
14
|
+
* show the most data. Nothing failed; it was just slower in one app than the other, for no
|
|
15
|
+
* reason anybody had decided.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
/** 'YYYY-MM-DD' with no time component — a labelled calendar day, not a moment. */
|
|
21
|
+
const BARE_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A SQLite timestamp ('YYYY-MM-DD HH:MM:SS', no offset) as a Date representing that wall-clock
|
|
25
|
+
* in UTC. Strings that already carry a designator (`Z` or `±HH:MM`) pass through unchanged.
|
|
26
|
+
*
|
|
27
|
+
* @returns {Date|null} null when unparseable — the caller shows the raw string rather than
|
|
28
|
+
* "Invalid Date", which at least lets somebody see what was stored.
|
|
29
|
+
*/
|
|
30
|
+
function parseDbTimestampAsUtc(input) {
|
|
31
|
+
const str = String(input).trim();
|
|
32
|
+
const hasTzSuffix = /Z$/i.test(str) || /[+-]\d{2}:?\d{2}$/.test(str);
|
|
33
|
+
// No designator means the value came from SQLite's datetime('now'), which is UTC.
|
|
34
|
+
const normalised = hasTzSuffix ? str.replace(' ', 'T') : str.replace(' ', 'T') + 'Z';
|
|
35
|
+
const d = new Date(normalised);
|
|
36
|
+
return isNaN(d.getTime()) ? null : d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Date and time parts as observed in an IANA timezone.
|
|
41
|
+
*
|
|
42
|
+
* `formatToParts` rather than a formatted string, so the caller controls the output shape
|
|
43
|
+
* completely — which is what lets two apps with different date orders share this.
|
|
44
|
+
*
|
|
45
|
+
* FORMATTERS ARE CACHED PER ZONE. Constructing an `Intl.DateTimeFormat` is millisecond-level
|
|
46
|
+
* work, and list pages call this once per row per column. The cache is bounded by the number of
|
|
47
|
+
* distinct zones an app ever renders, which is a handful, and ONLY successful constructions are
|
|
48
|
+
* stored — caching a failure would turn one bad setting into a permanent one.
|
|
49
|
+
*
|
|
50
|
+
* @returns {object|null} null when the timezone is unrecognised.
|
|
51
|
+
*/
|
|
52
|
+
const _fmtCache = new Map();
|
|
53
|
+
function partsInTimezone(date, tz) {
|
|
54
|
+
try {
|
|
55
|
+
let fmt = _fmtCache.get(tz);
|
|
56
|
+
if (!fmt) {
|
|
57
|
+
fmt = new Intl.DateTimeFormat('en-GB', {
|
|
58
|
+
timeZone: tz,
|
|
59
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
60
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
|
|
61
|
+
});
|
|
62
|
+
_fmtCache.set(tz, fmt);
|
|
63
|
+
}
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const p of fmt.formatToParts(date)) {
|
|
66
|
+
if (p.type !== 'literal') out[p.type] = p.value;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Warn ONCE per process per zone about a timezone nothing recognises.
|
|
76
|
+
*
|
|
77
|
+
* Once, because this is reached from a render path: a misconfigured display timezone would
|
|
78
|
+
* otherwise write a log line for every row of every page, which buries the one occurrence that
|
|
79
|
+
* would have told somebody to fix the setting.
|
|
80
|
+
*
|
|
81
|
+
* The logger is a parameter rather than a dependency — this package has none, deliberately, and
|
|
82
|
+
* an app that passes nothing gets silence rather than a crash.
|
|
83
|
+
*/
|
|
84
|
+
const _warned = new Set();
|
|
85
|
+
function warnInvalidTimezone(tz, logger) {
|
|
86
|
+
if (_warned.has(tz)) return;
|
|
87
|
+
_warned.add(tz);
|
|
88
|
+
if (logger && typeof logger.warn === 'function') {
|
|
89
|
+
logger.warn(`Unknown display timezone "${tz}" — falling back to UTC`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Test seam: forget which zones have been warned about. */
|
|
94
|
+
const _resetWarned = () => _warned.clear();
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
BARE_DATE_RE, parseDbTimestampAsUtc, partsInTimezone, warnInvalidTimezone, _resetWarned
|
|
98
|
+
};
|
package/fileSniff.js
CHANGED
|
@@ -1,210 +1,210 @@
|
|
|
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
|
-
/**
|
|
131
|
-
* Extension → MIME, for the cases MAGIC BYTES CANNOT SETTLE.
|
|
132
|
-
*
|
|
133
|
-
* .docx, .xlsx and a plain .zip are all ZIP containers; .doc and .xls are both OLE; .mp4, .mov,
|
|
134
|
-
* .m4a and .heic are all ISO-BMFF. The bytes identify the CONTAINER and stop there, so
|
|
135
|
-
* something else has to choose within it — and the only candidates are client-supplied.
|
|
136
|
-
*
|
|
137
|
-
* That is not a flaw in this table, it is the shape of the formats. What matters is that the
|
|
138
|
-
* caller is TOLD which answer it got: `from: 'content'` was decided by the bytes and cannot be
|
|
139
|
-
* influenced; `from: 'extension'` was narrowed by a client-supplied name inside a container the
|
|
140
|
-
* bytes did confirm. An app storing documents may accept the second; one serving them back
|
|
141
|
-
* inline should think about it.
|
|
142
|
-
*/
|
|
143
|
-
const EXT = {
|
|
144
|
-
doc: 'application/msword',
|
|
145
|
-
xls: 'application/vnd.ms-excel',
|
|
146
|
-
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
147
|
-
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
148
|
-
zip: 'application/zip',
|
|
149
|
-
heic: 'image/heic',
|
|
150
|
-
heif: 'image/heif',
|
|
151
|
-
mp4: 'video/mp4',
|
|
152
|
-
m4v: 'video/mp4',
|
|
153
|
-
mov: 'video/quicktime',
|
|
154
|
-
m4a: 'audio/x-m4a',
|
|
155
|
-
webm: 'video/webm',
|
|
156
|
-
mkv: 'video/webm',
|
|
157
|
-
avi: 'video/x-msvideo',
|
|
158
|
-
wav: 'audio/wav',
|
|
159
|
-
mp3: 'audio/mpeg',
|
|
160
|
-
txt: 'text/plain',
|
|
161
|
-
csv: 'text/csv'
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
/** Tags sniff() returns that ARE the answer, needing no extension to disambiguate. */
|
|
165
|
-
const CONCRETE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf']);
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* DECIDE the type, rather than checking someone else's claim.
|
|
169
|
-
*
|
|
170
|
-
* `matches()` asks "are these bytes consistent with what the client said?" — and then the caller
|
|
171
|
-
* usually stores what the client said. `resolve()` asks "what ARE these bytes?" and returns the
|
|
172
|
-
* answer to store. Where magic is unambiguous the client has no say at all; where it is not, the
|
|
173
|
-
* extension narrows within a container the bytes confirmed, and the result says so.
|
|
174
|
-
*
|
|
175
|
-
* Promoted from a consuming app that had written this by hand for five types, because deriving
|
|
176
|
-
* is strictly stronger than validating: a file whose bytes say nothing recognisable is REJECTED,
|
|
177
|
-
* rather than accepted under whatever label happened to arrive with it.
|
|
178
|
-
*
|
|
179
|
-
* @param {string|Buffer} input a path or the bytes
|
|
180
|
-
* @param {string} filename the client's name — used ONLY to disambiguate a container
|
|
181
|
-
* @param {Set} allow permitted MIME types; anything outside is rejected
|
|
182
|
-
* @returns {{mime: string, from: 'content'|'extension'}|null}
|
|
183
|
-
*/
|
|
184
|
-
function resolve(input, { filename = '', allow = null } = {}) {
|
|
185
|
-
const tag = sniff(input);
|
|
186
|
-
if (!tag) return null; // unrecognised bytes are refused, never guessed
|
|
187
|
-
|
|
188
|
-
const permitted = (m) => !allow || allow.has(m);
|
|
189
|
-
|
|
190
|
-
// 1. The bytes named it outright. The client's opinion is not consulted.
|
|
191
|
-
if (CONCRETE.has(tag)) return permitted(tag) ? { mime: tag, from: 'content' } : null;
|
|
192
|
-
|
|
193
|
-
// 2. A container. Narrow by extension, but only to something the container can actually hold —
|
|
194
|
-
// so a ZIP named .mp4 is refused rather than relabelled.
|
|
195
|
-
const ext = String(filename).toLowerCase().split('.').pop();
|
|
196
|
-
const byExt = EXT[ext];
|
|
197
|
-
if (!byExt) return null;
|
|
198
|
-
const acceptableTags = FAMILY[byExt] || [];
|
|
199
|
-
if (!acceptableTags.includes(tag)) return null;
|
|
200
|
-
return permitted(byExt) ? { mime: byExt, from: 'extension' } : null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function matches(input, declaredMime) {
|
|
204
|
-
const allowed = FAMILY[declaredMime];
|
|
205
|
-
if (!allowed) return false;
|
|
206
|
-
const sniffed = sniff(input);
|
|
207
|
-
return !!sniffed && allowed.includes(sniffed);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
module.exports = { sniff, matches, resolve };
|
|
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
|
+
/**
|
|
131
|
+
* Extension → MIME, for the cases MAGIC BYTES CANNOT SETTLE.
|
|
132
|
+
*
|
|
133
|
+
* .docx, .xlsx and a plain .zip are all ZIP containers; .doc and .xls are both OLE; .mp4, .mov,
|
|
134
|
+
* .m4a and .heic are all ISO-BMFF. The bytes identify the CONTAINER and stop there, so
|
|
135
|
+
* something else has to choose within it — and the only candidates are client-supplied.
|
|
136
|
+
*
|
|
137
|
+
* That is not a flaw in this table, it is the shape of the formats. What matters is that the
|
|
138
|
+
* caller is TOLD which answer it got: `from: 'content'` was decided by the bytes and cannot be
|
|
139
|
+
* influenced; `from: 'extension'` was narrowed by a client-supplied name inside a container the
|
|
140
|
+
* bytes did confirm. An app storing documents may accept the second; one serving them back
|
|
141
|
+
* inline should think about it.
|
|
142
|
+
*/
|
|
143
|
+
const EXT = {
|
|
144
|
+
doc: 'application/msword',
|
|
145
|
+
xls: 'application/vnd.ms-excel',
|
|
146
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
147
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
148
|
+
zip: 'application/zip',
|
|
149
|
+
heic: 'image/heic',
|
|
150
|
+
heif: 'image/heif',
|
|
151
|
+
mp4: 'video/mp4',
|
|
152
|
+
m4v: 'video/mp4',
|
|
153
|
+
mov: 'video/quicktime',
|
|
154
|
+
m4a: 'audio/x-m4a',
|
|
155
|
+
webm: 'video/webm',
|
|
156
|
+
mkv: 'video/webm',
|
|
157
|
+
avi: 'video/x-msvideo',
|
|
158
|
+
wav: 'audio/wav',
|
|
159
|
+
mp3: 'audio/mpeg',
|
|
160
|
+
txt: 'text/plain',
|
|
161
|
+
csv: 'text/csv'
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Tags sniff() returns that ARE the answer, needing no extension to disambiguate. */
|
|
165
|
+
const CONCRETE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* DECIDE the type, rather than checking someone else's claim.
|
|
169
|
+
*
|
|
170
|
+
* `matches()` asks "are these bytes consistent with what the client said?" — and then the caller
|
|
171
|
+
* usually stores what the client said. `resolve()` asks "what ARE these bytes?" and returns the
|
|
172
|
+
* answer to store. Where magic is unambiguous the client has no say at all; where it is not, the
|
|
173
|
+
* extension narrows within a container the bytes confirmed, and the result says so.
|
|
174
|
+
*
|
|
175
|
+
* Promoted from a consuming app that had written this by hand for five types, because deriving
|
|
176
|
+
* is strictly stronger than validating: a file whose bytes say nothing recognisable is REJECTED,
|
|
177
|
+
* rather than accepted under whatever label happened to arrive with it.
|
|
178
|
+
*
|
|
179
|
+
* @param {string|Buffer} input a path or the bytes
|
|
180
|
+
* @param {string} filename the client's name — used ONLY to disambiguate a container
|
|
181
|
+
* @param {Set} allow permitted MIME types; anything outside is rejected
|
|
182
|
+
* @returns {{mime: string, from: 'content'|'extension'}|null}
|
|
183
|
+
*/
|
|
184
|
+
function resolve(input, { filename = '', allow = null } = {}) {
|
|
185
|
+
const tag = sniff(input);
|
|
186
|
+
if (!tag) return null; // unrecognised bytes are refused, never guessed
|
|
187
|
+
|
|
188
|
+
const permitted = (m) => !allow || allow.has(m);
|
|
189
|
+
|
|
190
|
+
// 1. The bytes named it outright. The client's opinion is not consulted.
|
|
191
|
+
if (CONCRETE.has(tag)) return permitted(tag) ? { mime: tag, from: 'content' } : null;
|
|
192
|
+
|
|
193
|
+
// 2. A container. Narrow by extension, but only to something the container can actually hold —
|
|
194
|
+
// so a ZIP named .mp4 is refused rather than relabelled.
|
|
195
|
+
const ext = String(filename).toLowerCase().split('.').pop();
|
|
196
|
+
const byExt = EXT[ext];
|
|
197
|
+
if (!byExt) return null;
|
|
198
|
+
const acceptableTags = FAMILY[byExt] || [];
|
|
199
|
+
if (!acceptableTags.includes(tag)) return null;
|
|
200
|
+
return permitted(byExt) ? { mime: byExt, from: 'extension' } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function matches(input, declaredMime) {
|
|
204
|
+
const allowed = FAMILY[declaredMime];
|
|
205
|
+
if (!allowed) return false;
|
|
206
|
+
const sniffed = sniff(input);
|
|
207
|
+
return !!sniffed && allowed.includes(sniffed);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { sniff, matches, resolve };
|
package/index.js
CHANGED
|
@@ -1,31 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @aria-framework/kit — public API.
|
|
3
|
-
*
|
|
4
|
-
* Single-function modules are exported flat; multi-function modules keep a
|
|
5
|
-
* namespace (matching how call sites naturally read):
|
|
6
|
-
*
|
|
7
|
-
* safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
|
|
8
|
-
* escapeLike(s) — SQL LIKE escaping
|
|
9
|
-
* isEmail(v) — email-address validator
|
|
10
|
-
* fullName(first, last) / splitName(full) — person-name helpers
|
|
11
|
-
* fileSniff.sniff(path) / fileSniff.matches(path, mime)
|
|
12
|
-
* trackingId.generate() / trackingId.generateUnique(isTaken, maxAttempts)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/kit — public API.
|
|
3
|
+
*
|
|
4
|
+
* Single-function modules are exported flat; multi-function modules keep a
|
|
5
|
+
* namespace (matching how call sites naturally read):
|
|
6
|
+
*
|
|
7
|
+
* safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
|
|
8
|
+
* escapeLike(s) — SQL LIKE escaping
|
|
9
|
+
* isEmail(v) — email-address validator
|
|
10
|
+
* fullName(first, last) / splitName(full) — person-name helpers
|
|
11
|
+
* fileSniff.sniff(path) / fileSniff.matches(path, mime)
|
|
12
|
+
* trackingId.generate() / trackingId.generateUnique(isTaken, maxAttempts)
|
|
13
|
+
* asyncHandler(fn) — Express async-rejection wrapper
|
|
14
|
+
* dbTime.parseDbTimestampAsUtc / dbTime.partsInTimezone — a stored timestamp, in a timezone
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { escapeLike } = require('./likeEscape');
|
|
18
|
+
const { isEmail } = require('./isEmail');
|
|
19
|
+
const { fullName, splitName } = require('./personName');
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
safeReturnTo: require('./safeReturnTo'),
|
|
23
|
+
escapeLike,
|
|
24
|
+
isEmail,
|
|
25
|
+
fullName,
|
|
26
|
+
splitName,
|
|
27
|
+
fileSniff: require('./fileSniff'),
|
|
28
|
+
trackingId: require('./trackingId'),
|
|
29
|
+
// Page arithmetic for list views: the clamp, the past-the-end correction and the page-link
|
|
30
|
+
// window, which are the three things every hand-rolled paginator here got wrong or omitted.
|
|
31
|
+
paginate: require('./paginate').paginate,
|
|
32
|
+
PAGE_SIZES: require('./paginate').DEFAULT_SIZES,
|
|
33
|
+
asyncHandler: require('./asyncHandler'),
|
|
34
|
+
// The primitives under a date formatter, NOT the formatter: the two apps render dates
|
|
35
|
+
// differently on purpose (one is locale-aware because it invoices across countries), while
|
|
36
|
+
// everything beneath — parsing a stored timestamp, and asking what wall-clock it was in a
|
|
37
|
+
// zone — was identical and had already drifted on the Intl formatter cache.
|
|
38
|
+
dbTime: require('./dbTime')
|
|
39
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
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
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
"fileSniff.js",
|
|
17
17
|
"trackingId.js",
|
|
18
18
|
"personName.js",
|
|
19
|
-
"paginate.js"
|
|
19
|
+
"paginate.js",
|
|
20
|
+
"asyncHandler.js",
|
|
21
|
+
"dbTime.js"
|
|
20
22
|
],
|
|
21
23
|
"scripts": {
|
|
22
|
-
"test": "node test/smoke.js && node test/paginate.js"
|
|
24
|
+
"test": "node test/smoke.js && node test/paginate.js && node test/dbTime.js"
|
|
23
25
|
}
|
|
24
26
|
}
|
package/paginate.js
CHANGED
|
@@ -1,124 +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
|
-
* 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 };
|
|
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 };
|