@nifrajs/uploads 2.1.0 → 2.3.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/README.md +3 -3
- package/dist/detect.d.ts +2 -2
- package/dist/detect.js +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +5 -5
- package/dist/sign.d.ts +1 -1
- package/dist/sign.js +2 -2
- package/dist/strip.d.ts +4 -4
- package/dist/strip.js +2 -2
- package/dist/validate.d.ts +1 -1
- package/package.json +2 -2
- package/src/detect.ts +5 -5
- package/src/index.ts +5 -5
- package/src/sign.ts +2 -2
- package/src/strip.ts +4 -4
- package/src/validate.ts +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @nifrajs/uploads
|
|
2
2
|
|
|
3
|
-
File-upload hardening for nifra
|
|
3
|
+
File-upload hardening for nifra - magic-byte MIME detection, size + type validation, short-TTL signed download URLs, and EXIF/metadata stripping. Dependency-free, edge-safe.
|
|
4
4
|
|
|
5
|
-
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework
|
|
5
|
+
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ MIT
|
|
|
19
19
|
|
|
20
20
|
## For AI agents
|
|
21
21
|
|
|
22
|
-
Start with [`LLM.md`](./LLM.md)
|
|
22
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
23
23
|
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
24
24
|
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
25
25
|
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
package/dist/detect.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Magic-byte file-type detection
|
|
2
|
+
* Magic-byte file-type detection - trust the bytes, not the `Content-Type` header (which a client
|
|
3
3
|
* sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
|
|
4
4
|
* types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
|
|
5
|
-
* number
|
|
5
|
+
* number - handle those explicitly if you accept them).
|
|
6
6
|
*/
|
|
7
7
|
export interface FileType {
|
|
8
8
|
/** Detected MIME type, e.g. `"image/png"`. */
|
package/dist/detect.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Magic-byte file-type detection
|
|
2
|
+
* Magic-byte file-type detection - trust the bytes, not the `Content-Type` header (which a client
|
|
3
3
|
* sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
|
|
4
4
|
* types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
|
|
5
|
-
* number
|
|
5
|
+
* number - handle those explicitly if you accept them).
|
|
6
6
|
*/
|
|
7
7
|
/** Detect a file's type from its magic bytes, or `null` if unrecognized. */
|
|
8
8
|
export function detectFileType(bytes) {
|
|
@@ -14,7 +14,7 @@ export function detectFileType(bytes) {
|
|
|
14
14
|
return { mime: "image/png", ext: "png" };
|
|
15
15
|
if (at(0, 0x47, 0x49, 0x46, 0x38))
|
|
16
16
|
return { mime: "image/gif", ext: "gif" };
|
|
17
|
-
// RIFF container (bytes 0
|
|
17
|
+
// RIFF container (bytes 0-3 "RIFF", 8-11 the form type)
|
|
18
18
|
if (at(0, 0x52, 0x49, 0x46, 0x46)) {
|
|
19
19
|
if (at(8, 0x57, 0x45, 0x42, 0x50))
|
|
20
20
|
return { mime: "image/webp", ext: "webp" };
|
|
@@ -23,14 +23,14 @@ export function detectFileType(bytes) {
|
|
|
23
23
|
if (at(8, 0x41, 0x56, 0x49, 0x20))
|
|
24
24
|
return { mime: "video/x-msvideo", ext: "avi" };
|
|
25
25
|
}
|
|
26
|
-
// ISO-BMFF (`ftyp` box at offset 4)
|
|
26
|
+
// ISO-BMFF (`ftyp` box at offset 4) - mp4 / avif / heic / m4a, disambiguated by the major brand.
|
|
27
27
|
if (at(4, 0x66, 0x74, 0x79, 0x70)) {
|
|
28
28
|
const brand = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0);
|
|
29
29
|
if (brand === "avif" || brand === "avis")
|
|
30
30
|
return { mime: "image/avif", ext: "avif" };
|
|
31
31
|
if (brand.startsWith("hei") || brand === "mif1")
|
|
32
32
|
return { mime: "image/heic", ext: "heic" };
|
|
33
|
-
// Audio brands share the ISO-BMFF container but are NOT video
|
|
33
|
+
// Audio brands share the ISO-BMFF container but are NOT video - labeling them `video/mp4` would
|
|
34
34
|
// both reject real audio under an `audio/*` allow-list and admit it under `video/*`. `M4A `/`M4B `
|
|
35
35
|
// are AAC audio / audiobooks.
|
|
36
36
|
if (brand === "M4A " || brand === "M4B ")
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/uploads`
|
|
2
|
+
* `@nifrajs/uploads` - file-upload hardening, dependency-free + edge-safe.
|
|
3
3
|
*
|
|
4
|
-
* - {@link detectFileType}
|
|
5
|
-
* - {@link validateUpload}
|
|
4
|
+
* - {@link detectFileType} - sniff the real type from magic bytes (not the client's `Content-Type`).
|
|
5
|
+
* - {@link validateUpload} - size cap + magic-byte type allow-list. Pair with `@nifrajs/core`'s
|
|
6
6
|
* `c.boundedBody(maxBytes)` to also bound the read.
|
|
7
|
-
* - {@link signDownloadUrl} / {@link verifyDownloadUrl}
|
|
8
|
-
* - {@link stripImageMetadata}
|
|
7
|
+
* - {@link signDownloadUrl} / {@link verifyDownloadUrl} - short-TTL, tamper-evident download URLs.
|
|
8
|
+
* - {@link stripImageMetadata} - drop EXIF/GPS by re-encoding via any `@nifrajs/image` backend.
|
|
9
9
|
*/
|
|
10
10
|
export { detectFileType, type FileType } from "./detect.js";
|
|
11
11
|
export { type SignDownloadUrlOptions, signDownloadUrl, verifyDownloadUrl, } from "./sign.js";
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/uploads`
|
|
2
|
+
* `@nifrajs/uploads` - file-upload hardening, dependency-free + edge-safe.
|
|
3
3
|
*
|
|
4
|
-
* - {@link detectFileType}
|
|
5
|
-
* - {@link validateUpload}
|
|
4
|
+
* - {@link detectFileType} - sniff the real type from magic bytes (not the client's `Content-Type`).
|
|
5
|
+
* - {@link validateUpload} - size cap + magic-byte type allow-list. Pair with `@nifrajs/core`'s
|
|
6
6
|
* `c.boundedBody(maxBytes)` to also bound the read.
|
|
7
|
-
* - {@link signDownloadUrl} / {@link verifyDownloadUrl}
|
|
8
|
-
* - {@link stripImageMetadata}
|
|
7
|
+
* - {@link signDownloadUrl} / {@link verifyDownloadUrl} - short-TTL, tamper-evident download URLs.
|
|
8
|
+
* - {@link stripImageMetadata} - drop EXIF/GPS by re-encoding via any `@nifrajs/image` backend.
|
|
9
9
|
*/
|
|
10
10
|
export { detectFileType } from "./detect.js";
|
|
11
11
|
export { signDownloadUrl, verifyDownloadUrl, } from "./sign.js";
|
package/dist/sign.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Short-TTL **signed download URLs**
|
|
2
|
+
* Short-TTL **signed download URLs** - serve an uploaded file back via a tamper-evident, expiring URL
|
|
3
3
|
* (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
|
|
4
4
|
* no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
|
|
5
5
|
* URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
|
package/dist/sign.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Short-TTL **signed download URLs**
|
|
2
|
+
* Short-TTL **signed download URLs** - serve an uploaded file back via a tamper-evident, expiring URL
|
|
3
3
|
* (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
|
|
4
4
|
* no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
|
|
5
5
|
* URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
const ENC = new TextEncoder();
|
|
9
9
|
const DUMMY_BASE = "http://localhost"; // lets us parse a relative path/URL with the WHATWG URL parser
|
|
10
|
-
// 256-bit secret floor
|
|
10
|
+
// 256-bit secret floor - mirrors @nifrajs/core's HMAC guard. This package is deliberately
|
|
11
11
|
// dependency-free (ships its own crypto), so the constant is inlined rather than imported.
|
|
12
12
|
const MIN_SECRET_BYTES = 32;
|
|
13
13
|
const importKey = (secret) => {
|
package/dist/strip.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it
|
|
2
|
+
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it - decoders don't carry
|
|
3
3
|
* embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
|
|
4
4
|
*
|
|
5
5
|
* Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
|
|
6
|
-
* `wasmImageBackend`)
|
|
6
|
+
* `wasmImageBackend`) - the structural type below matches them, so this package takes **no** dependency
|
|
7
7
|
* on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
|
|
8
8
|
* (`validateUpload`) so you only re-encode real images.
|
|
9
9
|
*/
|
|
10
10
|
type StripFormat = "webp" | "jpeg" | "png";
|
|
11
|
-
/** The slice of `@nifrajs/image`'s `ImageBackend` this needs
|
|
11
|
+
/** The slice of `@nifrajs/image`'s `ImageBackend` this needs - `probe` for dims/format, `transform` to re-encode. */
|
|
12
12
|
export interface ImageReencoder {
|
|
13
13
|
probe(bytes: Uint8Array): Promise<{
|
|
14
14
|
readonly width: number;
|
|
@@ -27,7 +27,7 @@ export interface ImageReencoder {
|
|
|
27
27
|
export interface StripImageMetadataOptions {
|
|
28
28
|
/** Output format. Default: keep png/jpeg, else re-encode to webp. */
|
|
29
29
|
readonly format?: StripFormat;
|
|
30
|
-
/** Encode quality (1
|
|
30
|
+
/** Encode quality (1-100). Default 82. */
|
|
31
31
|
readonly quality?: number;
|
|
32
32
|
}
|
|
33
33
|
/** Re-encode an image to its intrinsic size, dropping all embedded metadata. Returns clean bytes. */
|
package/dist/strip.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it
|
|
2
|
+
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it - decoders don't carry
|
|
3
3
|
* embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
|
|
4
4
|
*
|
|
5
5
|
* Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
|
|
6
|
-
* `wasmImageBackend`)
|
|
6
|
+
* `wasmImageBackend`) - the structural type below matches them, so this package takes **no** dependency
|
|
7
7
|
* on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
|
|
8
8
|
* (`validateUpload`) so you only re-encode real images.
|
|
9
9
|
*/
|
package/dist/validate.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type UploadResult = {
|
|
|
18
18
|
export interface ValidateUploadOptions {
|
|
19
19
|
/** Max byte length. A `Blob` over this is rejected without reading it. */
|
|
20
20
|
readonly maxBytes: number;
|
|
21
|
-
/** Allowed MIME types
|
|
21
|
+
/** Allowed MIME types - exact (`"image/png"`) or a `image/*` wildcard. Omit ⇒ any recognized type. */
|
|
22
22
|
readonly accept?: readonly string[];
|
|
23
23
|
}
|
|
24
24
|
/** Validate uploaded bytes/Blob: size cap + magic-byte type sniff against an optional allow-list. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/uploads",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "File-upload hardening for nifra
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "File-upload hardening for nifra - magic-byte MIME detection, size + type validation, short-TTL signed download URLs, and EXIF/metadata stripping. Dependency-free, edge-safe.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
package/src/detect.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Magic-byte file-type detection
|
|
2
|
+
* Magic-byte file-type detection - trust the bytes, not the `Content-Type` header (which a client
|
|
3
3
|
* sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
|
|
4
4
|
* types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
|
|
5
|
-
* number
|
|
5
|
+
* number - handle those explicitly if you accept them).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export interface FileType {
|
|
@@ -23,19 +23,19 @@ export function detectFileType(bytes: Uint8Array): FileType | null {
|
|
|
23
23
|
return { mime: "image/png", ext: "png" }
|
|
24
24
|
if (at(0, 0x47, 0x49, 0x46, 0x38)) return { mime: "image/gif", ext: "gif" }
|
|
25
25
|
|
|
26
|
-
// RIFF container (bytes 0
|
|
26
|
+
// RIFF container (bytes 0-3 "RIFF", 8-11 the form type)
|
|
27
27
|
if (at(0, 0x52, 0x49, 0x46, 0x46)) {
|
|
28
28
|
if (at(8, 0x57, 0x45, 0x42, 0x50)) return { mime: "image/webp", ext: "webp" }
|
|
29
29
|
if (at(8, 0x57, 0x41, 0x56, 0x45)) return { mime: "audio/wav", ext: "wav" }
|
|
30
30
|
if (at(8, 0x41, 0x56, 0x49, 0x20)) return { mime: "video/x-msvideo", ext: "avi" }
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// ISO-BMFF (`ftyp` box at offset 4)
|
|
33
|
+
// ISO-BMFF (`ftyp` box at offset 4) - mp4 / avif / heic / m4a, disambiguated by the major brand.
|
|
34
34
|
if (at(4, 0x66, 0x74, 0x79, 0x70)) {
|
|
35
35
|
const brand = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0)
|
|
36
36
|
if (brand === "avif" || brand === "avis") return { mime: "image/avif", ext: "avif" }
|
|
37
37
|
if (brand.startsWith("hei") || brand === "mif1") return { mime: "image/heic", ext: "heic" }
|
|
38
|
-
// Audio brands share the ISO-BMFF container but are NOT video
|
|
38
|
+
// Audio brands share the ISO-BMFF container but are NOT video - labeling them `video/mp4` would
|
|
39
39
|
// both reject real audio under an `audio/*` allow-list and admit it under `video/*`. `M4A `/`M4B `
|
|
40
40
|
// are AAC audio / audiobooks.
|
|
41
41
|
if (brand === "M4A " || brand === "M4B ") return { mime: "audio/mp4", ext: "m4a" }
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/uploads`
|
|
2
|
+
* `@nifrajs/uploads` - file-upload hardening, dependency-free + edge-safe.
|
|
3
3
|
*
|
|
4
|
-
* - {@link detectFileType}
|
|
5
|
-
* - {@link validateUpload}
|
|
4
|
+
* - {@link detectFileType} - sniff the real type from magic bytes (not the client's `Content-Type`).
|
|
5
|
+
* - {@link validateUpload} - size cap + magic-byte type allow-list. Pair with `@nifrajs/core`'s
|
|
6
6
|
* `c.boundedBody(maxBytes)` to also bound the read.
|
|
7
|
-
* - {@link signDownloadUrl} / {@link verifyDownloadUrl}
|
|
8
|
-
* - {@link stripImageMetadata}
|
|
7
|
+
* - {@link signDownloadUrl} / {@link verifyDownloadUrl} - short-TTL, tamper-evident download URLs.
|
|
8
|
+
* - {@link stripImageMetadata} - drop EXIF/GPS by re-encoding via any `@nifrajs/image` backend.
|
|
9
9
|
*/
|
|
10
10
|
export { detectFileType, type FileType } from "./detect.ts"
|
|
11
11
|
export {
|
package/src/sign.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Short-TTL **signed download URLs**
|
|
2
|
+
* Short-TTL **signed download URLs** - serve an uploaded file back via a tamper-evident, expiring URL
|
|
3
3
|
* (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
|
|
4
4
|
* no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
|
|
5
5
|
* URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const ENC = new TextEncoder()
|
|
10
10
|
const DUMMY_BASE = "http://localhost" // lets us parse a relative path/URL with the WHATWG URL parser
|
|
11
11
|
|
|
12
|
-
// 256-bit secret floor
|
|
12
|
+
// 256-bit secret floor - mirrors @nifrajs/core's HMAC guard. This package is deliberately
|
|
13
13
|
// dependency-free (ships its own crypto), so the constant is inlined rather than imported.
|
|
14
14
|
const MIN_SECRET_BYTES = 32
|
|
15
15
|
|
package/src/strip.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it
|
|
2
|
+
* Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it - decoders don't carry
|
|
3
3
|
* embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
|
|
4
4
|
*
|
|
5
5
|
* Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
|
|
6
|
-
* `wasmImageBackend`)
|
|
6
|
+
* `wasmImageBackend`) - the structural type below matches them, so this package takes **no** dependency
|
|
7
7
|
* on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
|
|
8
8
|
* (`validateUpload`) so you only re-encode real images.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
type StripFormat = "webp" | "jpeg" | "png"
|
|
12
12
|
|
|
13
|
-
/** The slice of `@nifrajs/image`'s `ImageBackend` this needs
|
|
13
|
+
/** The slice of `@nifrajs/image`'s `ImageBackend` this needs - `probe` for dims/format, `transform` to re-encode. */
|
|
14
14
|
export interface ImageReencoder {
|
|
15
15
|
probe(
|
|
16
16
|
bytes: Uint8Array,
|
|
@@ -26,7 +26,7 @@ export interface ImageReencoder {
|
|
|
26
26
|
export interface StripImageMetadataOptions {
|
|
27
27
|
/** Output format. Default: keep png/jpeg, else re-encode to webp. */
|
|
28
28
|
readonly format?: StripFormat
|
|
29
|
-
/** Encode quality (1
|
|
29
|
+
/** Encode quality (1-100). Default 82. */
|
|
30
30
|
readonly quality?: number
|
|
31
31
|
}
|
|
32
32
|
|
package/src/validate.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type UploadResult =
|
|
|
18
18
|
export interface ValidateUploadOptions {
|
|
19
19
|
/** Max byte length. A `Blob` over this is rejected without reading it. */
|
|
20
20
|
readonly maxBytes: number
|
|
21
|
-
/** Allowed MIME types
|
|
21
|
+
/** Allowed MIME types - exact (`"image/png"`) or a `image/*` wildcard. Omit ⇒ any recognized type. */
|
|
22
22
|
readonly accept?: readonly string[]
|
|
23
23
|
}
|
|
24
24
|
|