@aria-framework/kit 0.4.0 → 0.5.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 (2) hide show
  1. package/fileSniff.js +74 -1
  2. package/package.json +1 -1
package/fileSniff.js CHANGED
@@ -127,6 +127,79 @@ const FAMILY = {
127
127
  * @param {string} declaredMime
128
128
  * @returns {boolean}
129
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
+
130
203
  function matches(input, declaredMime) {
131
204
  const allowed = FAMILY[declaredMime];
132
205
  if (!allowed) return false;
@@ -134,4 +207,4 @@ function matches(input, declaredMime) {
134
207
  return !!sniffed && allowed.includes(sniffed);
135
208
  }
136
209
 
137
- module.exports = { sniff, matches };
210
+ module.exports = { sniff, matches, resolve };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aria-framework/kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.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,