@nifrajs/uploads 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @nifrajs/uploads
2
+
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
+
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
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @nifrajs/uploads
11
+ ```
12
+
13
+ ## Docs
14
+
15
+ - Reference: <https://nifra.dev/docs>
16
+ - AI-readable: <https://nifra.dev/llms.txt>
17
+
18
+ MIT
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Magic-byte file-type detection — trust the bytes, not the `Content-Type` header (which a client
3
+ * sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
4
+ * types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
5
+ * number — handle those explicitly if you accept them).
6
+ */
7
+ export interface FileType {
8
+ /** Detected MIME type, e.g. `"image/png"`. */
9
+ readonly mime: string;
10
+ /** Canonical extension (no dot), e.g. `"png"`. */
11
+ readonly ext: string;
12
+ }
13
+ /** Detect a file's type from its magic bytes, or `null` if unrecognized. */
14
+ export declare function detectFileType(bytes: Uint8Array): FileType | null;
15
+ //# sourceMappingURL=detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,QAAQ;IACvB,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kDAAkD;IAClD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,GAAG,IAAI,CAwCjE"}
package/dist/detect.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Magic-byte file-type detection — trust the bytes, not the `Content-Type` header (which a client
3
+ * sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
4
+ * types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
5
+ * number — handle those explicitly if you accept them).
6
+ */
7
+ /** Detect a file's type from its magic bytes, or `null` if unrecognized. */
8
+ export function detectFileType(bytes) {
9
+ const at = (offset, ...sig) => sig.every((byte, i) => bytes[offset + i] === byte);
10
+ // Images
11
+ if (at(0, 0xff, 0xd8, 0xff))
12
+ return { mime: "image/jpeg", ext: "jpg" };
13
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
14
+ return { mime: "image/png", ext: "png" };
15
+ if (at(0, 0x47, 0x49, 0x46, 0x38))
16
+ return { mime: "image/gif", ext: "gif" };
17
+ // RIFF container (bytes 0–3 "RIFF", 8–11 the form type)
18
+ if (at(0, 0x52, 0x49, 0x46, 0x46)) {
19
+ if (at(8, 0x57, 0x45, 0x42, 0x50))
20
+ return { mime: "image/webp", ext: "webp" };
21
+ if (at(8, 0x57, 0x41, 0x56, 0x45))
22
+ return { mime: "audio/wav", ext: "wav" };
23
+ if (at(8, 0x41, 0x56, 0x49, 0x20))
24
+ return { mime: "video/x-msvideo", ext: "avi" };
25
+ }
26
+ // ISO-BMFF (`ftyp` box at offset 4) — mp4 / avif / heic / m4a, disambiguated by the major brand.
27
+ if (at(4, 0x66, 0x74, 0x79, 0x70)) {
28
+ const brand = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0);
29
+ if (brand === "avif" || brand === "avis")
30
+ return { mime: "image/avif", ext: "avif" };
31
+ if (brand.startsWith("hei") || brand === "mif1")
32
+ return { mime: "image/heic", ext: "heic" };
33
+ // Audio brands share the ISO-BMFF container but are NOT video — labeling them `video/mp4` would
34
+ // both reject real audio under an `audio/*` allow-list and admit it under `video/*`. `M4A `/`M4B `
35
+ // are AAC audio / audiobooks.
36
+ if (brand === "M4A " || brand === "M4B ")
37
+ return { mime: "audio/mp4", ext: "m4a" };
38
+ return { mime: "video/mp4", ext: "mp4" };
39
+ }
40
+ // A/V + archives
41
+ if (at(0, 0x1a, 0x45, 0xdf, 0xa3))
42
+ return { mime: "video/webm", ext: "webm" }; // Matroska/WebM
43
+ if (at(0, 0x4f, 0x67, 0x67, 0x53))
44
+ return { mime: "audio/ogg", ext: "ogg" };
45
+ if (at(0, 0x49, 0x44, 0x33))
46
+ return { mime: "audio/mpeg", ext: "mp3" }; // ID3 tag
47
+ if (at(0, 0x25, 0x50, 0x44, 0x46))
48
+ return { mime: "application/pdf", ext: "pdf" }; // "%PDF"
49
+ if (at(0, 0x50, 0x4b, 0x03, 0x04) || at(0, 0x50, 0x4b, 0x05, 0x06)) {
50
+ return { mime: "application/zip", ext: "zip" }; // also docx/xlsx/odt containers
51
+ }
52
+ if (at(0, 0x1f, 0x8b))
53
+ return { mime: "application/gzip", ext: "gz" };
54
+ return null;
55
+ }
56
+ //# sourceMappingURL=detect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.js","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@nifrajs/uploads` — file-upload hardening, dependency-free + edge-safe.
3
+ *
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
+ * `c.boundedBody(maxBytes)` to also bound the read.
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
+ */
10
+ export { detectFileType, type FileType } from "./detect.js";
11
+ export { type SignDownloadUrlOptions, signDownloadUrl, verifyDownloadUrl, } from "./sign.js";
12
+ export { type ImageReencoder, type StripImageMetadataOptions, stripImageMetadata, } from "./strip.js";
13
+ export { type UploadResult, type ValidateUploadOptions, validateUpload, } from "./validate.js";
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAA;AAC3D,OAAO,EACL,KAAK,sBAAsB,EAC3B,eAAe,EACf,iBAAiB,GAClB,MAAM,WAAW,CAAA;AAClB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,yBAAyB,EAC9B,kBAAkB,GACnB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,cAAc,GACf,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@nifrajs/uploads` — file-upload hardening, dependency-free + edge-safe.
3
+ *
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
+ * `c.boundedBody(maxBytes)` to also bound the read.
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
+ */
10
+ export { detectFileType } from "./detect.js";
11
+ export { signDownloadUrl, verifyDownloadUrl, } from "./sign.js";
12
+ export { stripImageMetadata, } from "./strip.js";
13
+ export { validateUpload, } from "./validate.js";
14
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAiB,MAAM,aAAa,CAAA;AAC3D,OAAO,EAEL,eAAe,EACf,iBAAiB,GAClB,MAAM,WAAW,CAAA;AAClB,OAAO,EAGL,kBAAkB,GACnB,MAAM,YAAY,CAAA;AACnB,OAAO,EAGL,cAAc,GACf,MAAM,eAAe,CAAA"}
package/dist/sign.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Short-TTL **signed download URLs** — serve an uploaded file back via a tamper-evident, expiring URL
3
+ * (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
4
+ * no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
5
+ * URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
6
+ * signature fields and are excluded from the payload.
7
+ */
8
+ export interface SignDownloadUrlOptions {
9
+ /** Seconds until the URL expires. */
10
+ readonly expiresInSeconds: number;
11
+ /** Current unix time (seconds); defaults to `Date.now()/1000`. Injectable for tests. */
12
+ readonly now?: number;
13
+ }
14
+ /** Sign a relative URL/path → a relative URL with `?vexp=&vsig=` appended. */
15
+ export declare function signDownloadUrl(url: string, secret: string, options: SignDownloadUrlOptions): Promise<string>;
16
+ /** Verify a URL produced by {@link signDownloadUrl}: signature (constant-time) + not expired. */
17
+ export declare function verifyDownloadUrl(url: string, secret: string, options?: {
18
+ readonly now?: number;
19
+ }): Promise<boolean>;
20
+ //# sourceMappingURL=sign.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sign.d.ts","sourceRoot":"","sources":["../src/sign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA8DH,MAAM,WAAW,sBAAsB;IACrC,qCAAqC;IACrC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,wFAAwF;IACxF,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,8EAA8E;AAC9E,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAcjB;AAED,iGAAiG;AACjG,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAClC,OAAO,CAAC,OAAO,CAAC,CAelB"}
package/dist/sign.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Short-TTL **signed download URLs** — serve an uploaded file back via a tamper-evident, expiring URL
3
+ * (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
4
+ * no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
5
+ * URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
6
+ * signature fields and are excluded from the payload.
7
+ */
8
+ const ENC = new TextEncoder();
9
+ const DUMMY_BASE = "http://localhost"; // lets us parse a relative path/URL with the WHATWG URL parser
10
+ // 256-bit secret floor — mirrors @nifrajs/core's HMAC guard. This package is deliberately
11
+ // dependency-free (ships its own crypto), so the constant is inlined rather than imported.
12
+ const MIN_SECRET_BYTES = 32;
13
+ const importKey = (secret) => {
14
+ if (ENC.encode(secret).length < MIN_SECRET_BYTES) {
15
+ throw new Error(`[nifra/uploads] signing secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`);
16
+ }
17
+ return crypto.subtle.importKey("raw", ENC.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
18
+ };
19
+ const toBase64Url = (buf) => {
20
+ const bytes = new Uint8Array(buf);
21
+ let s = "";
22
+ for (const byte of bytes)
23
+ s += String.fromCharCode(byte);
24
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ };
26
+ const fromBase64Url = (value) => {
27
+ try {
28
+ const b = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
29
+ const out = new Uint8Array(b.length);
30
+ for (let i = 0; i < b.length; i++)
31
+ out[i] = b.charCodeAt(i);
32
+ return out;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ };
38
+ const SIGNATURE_PARAMS = new Set(["vexp", "vsig"]);
39
+ function signedQueryPairs(u) {
40
+ const pairs = [];
41
+ for (const [key, value] of u.searchParams) {
42
+ if (!SIGNATURE_PARAMS.has(key))
43
+ pairs.push([key, value]);
44
+ }
45
+ return pairs;
46
+ }
47
+ function getSingleParam(u, name) {
48
+ const values = u.searchParams.getAll(name);
49
+ return values.length === 1 ? (values[0] ?? null) : null;
50
+ }
51
+ // Sign the path + non-signature query pairs + expiry. Query pair order is intentional: a consumer may
52
+ // give repeated params first-value semantics, so reordering must not preserve the signature.
53
+ const canonical = (u, exp) => `${u.pathname}\n${JSON.stringify(signedQueryPairs(u))}\n${exp}`;
54
+ /** Sign a relative URL/path → a relative URL with `?vexp=&vsig=` appended. */
55
+ export async function signDownloadUrl(url, secret, options) {
56
+ const u = new URL(url, DUMMY_BASE);
57
+ u.searchParams.delete("vexp");
58
+ u.searchParams.delete("vsig");
59
+ const now = options.now ?? Math.floor(Date.now() / 1000);
60
+ const exp = now + options.expiresInSeconds;
61
+ const sig = await crypto.subtle.sign("HMAC", await importKey(secret), ENC.encode(canonical(u, exp)));
62
+ u.searchParams.set("vexp", String(exp));
63
+ u.searchParams.set("vsig", toBase64Url(sig));
64
+ return u.pathname + u.search;
65
+ }
66
+ /** Verify a URL produced by {@link signDownloadUrl}: signature (constant-time) + not expired. */
67
+ export async function verifyDownloadUrl(url, secret, options) {
68
+ const u = new URL(url, DUMMY_BASE);
69
+ const exp = getSingleParam(u, "vexp");
70
+ const sig = getSingleParam(u, "vsig");
71
+ if (exp === null || sig === null || !/^\d+$/.test(exp))
72
+ return false;
73
+ const now = options?.now ?? Math.floor(Date.now() / 1000);
74
+ if (Number(exp) < now)
75
+ return false;
76
+ const sigBytes = fromBase64Url(sig);
77
+ if (sigBytes === null)
78
+ return false;
79
+ return crypto.subtle.verify("HMAC", await importKey(secret), sigBytes, ENC.encode(canonical(u, Number(exp))));
80
+ }
81
+ //# sourceMappingURL=sign.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sign.js","sourceRoot":"","sources":["../src/sign.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it — decoders don't carry
3
+ * embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
4
+ *
5
+ * Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
6
+ * `wasmImageBackend`) — the structural type below matches them, so this package takes **no** dependency
7
+ * on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
8
+ * (`validateUpload`) so you only re-encode real images.
9
+ */
10
+ type StripFormat = "webp" | "jpeg" | "png";
11
+ /** The slice of `@nifrajs/image`'s `ImageBackend` this needs — `probe` for dims/format, `transform` to re-encode. */
12
+ export interface ImageReencoder {
13
+ probe(bytes: Uint8Array): Promise<{
14
+ readonly width: number;
15
+ readonly height: number;
16
+ readonly format: string;
17
+ }>;
18
+ transform(input: {
19
+ readonly bytes: Uint8Array;
20
+ readonly width: number;
21
+ readonly quality: number;
22
+ readonly format: StripFormat;
23
+ }): Promise<{
24
+ readonly bytes: Uint8Array;
25
+ }>;
26
+ }
27
+ export interface StripImageMetadataOptions {
28
+ /** Output format. Default: keep png/jpeg, else re-encode to webp. */
29
+ readonly format?: StripFormat;
30
+ /** Encode quality (1–100). Default 82. */
31
+ readonly quality?: number;
32
+ }
33
+ /** Re-encode an image to its intrinsic size, dropping all embedded metadata. Returns clean bytes. */
34
+ export declare function stripImageMetadata(bytes: Uint8Array, backend: ImageReencoder, options?: StripImageMetadataOptions): Promise<Uint8Array>;
35
+ export {};
36
+ //# sourceMappingURL=strip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.d.ts","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,KAAK,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAA;AAE1C,qHAAqH;AACrH,MAAM,WAAW,cAAc;IAC7B,KAAK,CACH,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACxF,SAAS,CAAC,KAAK,EAAE;QACf,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;QAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;QACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;QACxB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;KAC7B,GAAG,OAAO,CAAC;QAAE,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;KAAE,CAAC,CAAA;CAC5C;AAED,MAAM,WAAW,yBAAyB;IACxC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;IAC7B,0CAA0C;IAC1C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,qGAAqG;AACrG,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,UAAU,CAAC,CAWrB"}
package/dist/strip.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it — decoders don't carry
3
+ * embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
4
+ *
5
+ * Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
6
+ * `wasmImageBackend`) — the structural type below matches them, so this package takes **no** dependency
7
+ * on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
8
+ * (`validateUpload`) so you only re-encode real images.
9
+ */
10
+ /** Re-encode an image to its intrinsic size, dropping all embedded metadata. Returns clean bytes. */
11
+ export async function stripImageMetadata(bytes, backend, options = {}) {
12
+ const probed = await backend.probe(bytes);
13
+ const format = options.format ?? (probed.format === "png" ? "png" : probed.format === "jpeg" ? "jpeg" : "webp");
14
+ const out = await backend.transform({
15
+ bytes,
16
+ width: probed.width,
17
+ quality: options.quality ?? 82,
18
+ format,
19
+ });
20
+ return out.bytes;
21
+ }
22
+ //# sourceMappingURL=strip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strip.js","sourceRoot":"","sources":["../src/strip.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,qGAAqG;AACrG,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,KAAiB,EACjB,OAAuB,EACvB,UAAqC,EAAE;IAEvC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IACzC,MAAM,MAAM,GACV,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;IAClG,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC;QAClC,KAAK;QACL,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,EAAE;QAC9B,MAAM;KACP,CAAC,CAAA;IACF,OAAO,GAAG,CAAC,KAAK,CAAA;AAClB,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { type FileType } from "./detect.js";
2
+ /**
3
+ * Validate an upload's **size** and **real type** (by magic bytes, not the client's `Content-Type`).
4
+ * Pair with `c.boundedBody(maxBytes)` from `@nifrajs/core` to bound the *read* itself: read the body
5
+ * under the cap, then `validateUpload(bytes, …)` to confirm the size + sniff the type.
6
+ */
7
+ export type UploadResult = {
8
+ readonly ok: true;
9
+ readonly mime: string;
10
+ readonly ext: string;
11
+ readonly bytes: Uint8Array;
12
+ } | {
13
+ readonly ok: false;
14
+ readonly reason: "too_large" | "empty" | "unrecognized" | "type_not_allowed";
15
+ /** The sniffed type, when one was detected (set for `type_not_allowed`). */
16
+ readonly detected?: FileType;
17
+ };
18
+ export interface ValidateUploadOptions {
19
+ /** Max byte length. A `Blob` over this is rejected without reading it. */
20
+ readonly maxBytes: number;
21
+ /** Allowed MIME types — exact (`"image/png"`) or a `image/*` wildcard. Omit ⇒ any recognized type. */
22
+ readonly accept?: readonly string[];
23
+ }
24
+ /** Validate uploaded bytes/Blob: size cap + magic-byte type sniff against an optional allow-list. */
25
+ export declare function validateUpload(input: Uint8Array | ArrayBuffer | Blob, options: ValidateUploadOptions): Promise<UploadResult>;
26
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3D;;;;GAIG;AAEH,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GAC9F;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAA;IAClB,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,GAAG,kBAAkB,CAAA;IAC5E,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CAC7B,CAAA;AAEL,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,sGAAsG;IACtG,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACpC;AAKD,qGAAqG;AACrG,wBAAsB,cAAc,CAClC,KAAK,EAAE,UAAU,GAAG,WAAW,GAAG,IAAI,EACtC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAuBvB"}
@@ -0,0 +1,26 @@
1
+ import { detectFileType } from "./detect.js";
2
+ const matchesAccept = (mime, pattern) => pattern === mime || (pattern.endsWith("/*") && mime.startsWith(pattern.slice(0, -1)));
3
+ /** Validate uploaded bytes/Blob: size cap + magic-byte type sniff against an optional allow-list. */
4
+ export async function validateUpload(input, options) {
5
+ // Reject an oversized Blob by its declared size before buffering it into memory.
6
+ if (input instanceof Blob && input.size > options.maxBytes)
7
+ return { ok: false, reason: "too_large" };
8
+ const bytes = input instanceof Uint8Array
9
+ ? input
10
+ : input instanceof ArrayBuffer
11
+ ? new Uint8Array(input)
12
+ : new Uint8Array(await input.arrayBuffer());
13
+ if (bytes.byteLength > options.maxBytes)
14
+ return { ok: false, reason: "too_large" };
15
+ if (bytes.byteLength === 0)
16
+ return { ok: false, reason: "empty" };
17
+ const detected = detectFileType(bytes);
18
+ if (detected === null)
19
+ return { ok: false, reason: "unrecognized" };
20
+ if (options.accept !== undefined &&
21
+ !options.accept.some((p) => matchesAccept(detected.mime, p))) {
22
+ return { ok: false, reason: "type_not_allowed", detected };
23
+ }
24
+ return { ok: true, mime: detected.mime, ext: detected.ext, bytes };
25
+ }
26
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAiB,MAAM,aAAa,CAAA;AAwB3D,MAAM,aAAa,GAAG,CAAC,IAAY,EAAE,OAAe,EAAW,EAAE,CAC/D,OAAO,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAEvF,qGAAqG;AACrG,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,KAAsC,EACtC,OAA8B;IAE9B,iFAAiF;IACjF,IAAI,KAAK,YAAY,IAAI,IAAI,KAAK,CAAC,IAAI,GAAG,OAAO,CAAC,QAAQ;QACxD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;IAC3C,MAAM,KAAK,GACT,KAAK,YAAY,UAAU;QACzB,CAAC,CAAC,KAAK;QACP,CAAC,CAAC,KAAK,YAAY,WAAW;YAC5B,CAAC,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC;YACvB,CAAC,CAAC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,WAAW,EAAE,CAAC,CAAA;IAEjD,IAAI,KAAK,CAAC,UAAU,GAAG,OAAO,CAAC,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;IAClF,IAAI,KAAK,CAAC,UAAU,KAAK,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAA;IAEjE,MAAM,QAAQ,GAAG,cAAc,CAAC,KAAK,CAAC,CAAA;IACtC,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,CAAA;IACnE,IACE,OAAO,CAAC,MAAM,KAAK,SAAS;QAC5B,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAC5D,CAAC;QACD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,CAAA;IAC5D,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,EAAE,CAAA;AACpE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@nifrajs/uploads",
3
+ "version": "0.1.0-alpha.1",
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
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr",
13
+ "file-upload",
14
+ "s3",
15
+ "presigned-url",
16
+ "magic-bytes"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "bun": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "sideEffects": false,
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
36
+ },
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/nifrajs/nifra.git",
44
+ "directory": "packages/uploads"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/nifrajs/nifra/issues"
48
+ },
49
+ "homepage": "https://github.com/nifrajs/nifra#readme"
50
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Magic-byte file-type detection — trust the bytes, not the `Content-Type` header (which a client
3
+ * sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload
4
+ * types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic
5
+ * number — handle those explicitly if you accept them).
6
+ */
7
+
8
+ export interface FileType {
9
+ /** Detected MIME type, e.g. `"image/png"`. */
10
+ readonly mime: string
11
+ /** Canonical extension (no dot), e.g. `"png"`. */
12
+ readonly ext: string
13
+ }
14
+
15
+ /** Detect a file's type from its magic bytes, or `null` if unrecognized. */
16
+ export function detectFileType(bytes: Uint8Array): FileType | null {
17
+ const at = (offset: number, ...sig: number[]): boolean =>
18
+ sig.every((byte, i) => bytes[offset + i] === byte)
19
+
20
+ // Images
21
+ if (at(0, 0xff, 0xd8, 0xff)) return { mime: "image/jpeg", ext: "jpg" }
22
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
23
+ return { mime: "image/png", ext: "png" }
24
+ if (at(0, 0x47, 0x49, 0x46, 0x38)) return { mime: "image/gif", ext: "gif" }
25
+
26
+ // RIFF container (bytes 0–3 "RIFF", 8–11 the form type)
27
+ if (at(0, 0x52, 0x49, 0x46, 0x46)) {
28
+ if (at(8, 0x57, 0x45, 0x42, 0x50)) return { mime: "image/webp", ext: "webp" }
29
+ if (at(8, 0x57, 0x41, 0x56, 0x45)) return { mime: "audio/wav", ext: "wav" }
30
+ if (at(8, 0x41, 0x56, 0x49, 0x20)) return { mime: "video/x-msvideo", ext: "avi" }
31
+ }
32
+
33
+ // ISO-BMFF (`ftyp` box at offset 4) — mp4 / avif / heic / m4a, disambiguated by the major brand.
34
+ if (at(4, 0x66, 0x74, 0x79, 0x70)) {
35
+ const brand = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0)
36
+ if (brand === "avif" || brand === "avis") return { mime: "image/avif", ext: "avif" }
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 — labeling them `video/mp4` would
39
+ // both reject real audio under an `audio/*` allow-list and admit it under `video/*`. `M4A `/`M4B `
40
+ // are AAC audio / audiobooks.
41
+ if (brand === "M4A " || brand === "M4B ") return { mime: "audio/mp4", ext: "m4a" }
42
+ return { mime: "video/mp4", ext: "mp4" }
43
+ }
44
+
45
+ // A/V + archives
46
+ if (at(0, 0x1a, 0x45, 0xdf, 0xa3)) return { mime: "video/webm", ext: "webm" } // Matroska/WebM
47
+ if (at(0, 0x4f, 0x67, 0x67, 0x53)) return { mime: "audio/ogg", ext: "ogg" }
48
+ if (at(0, 0x49, 0x44, 0x33)) return { mime: "audio/mpeg", ext: "mp3" } // ID3 tag
49
+ if (at(0, 0x25, 0x50, 0x44, 0x46)) return { mime: "application/pdf", ext: "pdf" } // "%PDF"
50
+ if (at(0, 0x50, 0x4b, 0x03, 0x04) || at(0, 0x50, 0x4b, 0x05, 0x06)) {
51
+ return { mime: "application/zip", ext: "zip" } // also docx/xlsx/odt containers
52
+ }
53
+ if (at(0, 0x1f, 0x8b)) return { mime: "application/gzip", ext: "gz" }
54
+
55
+ return null
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `@nifrajs/uploads` — file-upload hardening, dependency-free + edge-safe.
3
+ *
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
+ * `c.boundedBody(maxBytes)` to also bound the read.
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
+ */
10
+ export { detectFileType, type FileType } from "./detect.ts"
11
+ export {
12
+ type SignDownloadUrlOptions,
13
+ signDownloadUrl,
14
+ verifyDownloadUrl,
15
+ } from "./sign.ts"
16
+ export {
17
+ type ImageReencoder,
18
+ type StripImageMetadataOptions,
19
+ stripImageMetadata,
20
+ } from "./strip.ts"
21
+ export {
22
+ type UploadResult,
23
+ type ValidateUploadOptions,
24
+ validateUpload,
25
+ } from "./validate.ts"
package/src/sign.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Short-TTL **signed download URLs** — serve an uploaded file back via a tamper-evident, expiring URL
3
+ * (HMAC-SHA256 over the path + query + expiry). WebCrypto only (portable across Bun/Node/Deno/workerd,
4
+ * no `node:crypto`). Verification is constant-time (`crypto.subtle.verify`). Sign a *relative*
5
+ * URL/path; the returned string is relative so it works behind any host. `vexp` / `vsig` are reserved
6
+ * signature fields and are excluded from the payload.
7
+ */
8
+
9
+ const ENC = new TextEncoder()
10
+ const DUMMY_BASE = "http://localhost" // lets us parse a relative path/URL with the WHATWG URL parser
11
+
12
+ // 256-bit secret floor — mirrors @nifrajs/core's HMAC guard. This package is deliberately
13
+ // dependency-free (ships its own crypto), so the constant is inlined rather than imported.
14
+ const MIN_SECRET_BYTES = 32
15
+
16
+ const importKey = (secret: string): Promise<CryptoKey> => {
17
+ if (ENC.encode(secret).length < MIN_SECRET_BYTES) {
18
+ throw new Error(
19
+ `[nifra/uploads] signing secret must be at least ${MIN_SECRET_BYTES} bytes (256-bit). Generate one with: openssl rand -base64 32`,
20
+ )
21
+ }
22
+ return crypto.subtle.importKey(
23
+ "raw",
24
+ ENC.encode(secret),
25
+ { name: "HMAC", hash: "SHA-256" },
26
+ false,
27
+ ["sign", "verify"],
28
+ )
29
+ }
30
+
31
+ const toBase64Url = (buf: ArrayBuffer): string => {
32
+ const bytes = new Uint8Array(buf)
33
+ let s = ""
34
+ for (const byte of bytes) s += String.fromCharCode(byte)
35
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
36
+ }
37
+
38
+ const fromBase64Url = (value: string): Uint8Array<ArrayBuffer> | null => {
39
+ try {
40
+ const b = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
41
+ const out = new Uint8Array(b.length)
42
+ for (let i = 0; i < b.length; i++) out[i] = b.charCodeAt(i)
43
+ return out
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ const SIGNATURE_PARAMS = new Set(["vexp", "vsig"])
50
+
51
+ function signedQueryPairs(u: URL): ReadonlyArray<readonly [string, string]> {
52
+ const pairs: Array<readonly [string, string]> = []
53
+ for (const [key, value] of u.searchParams) {
54
+ if (!SIGNATURE_PARAMS.has(key)) pairs.push([key, value])
55
+ }
56
+ return pairs
57
+ }
58
+
59
+ function getSingleParam(u: URL, name: string): string | null {
60
+ const values = u.searchParams.getAll(name)
61
+ return values.length === 1 ? (values[0] ?? null) : null
62
+ }
63
+
64
+ // Sign the path + non-signature query pairs + expiry. Query pair order is intentional: a consumer may
65
+ // give repeated params first-value semantics, so reordering must not preserve the signature.
66
+ const canonical = (u: URL, exp: number): string =>
67
+ `${u.pathname}\n${JSON.stringify(signedQueryPairs(u))}\n${exp}`
68
+
69
+ export interface SignDownloadUrlOptions {
70
+ /** Seconds until the URL expires. */
71
+ readonly expiresInSeconds: number
72
+ /** Current unix time (seconds); defaults to `Date.now()/1000`. Injectable for tests. */
73
+ readonly now?: number
74
+ }
75
+
76
+ /** Sign a relative URL/path → a relative URL with `?vexp=&vsig=` appended. */
77
+ export async function signDownloadUrl(
78
+ url: string,
79
+ secret: string,
80
+ options: SignDownloadUrlOptions,
81
+ ): Promise<string> {
82
+ const u = new URL(url, DUMMY_BASE)
83
+ u.searchParams.delete("vexp")
84
+ u.searchParams.delete("vsig")
85
+ const now = options.now ?? Math.floor(Date.now() / 1000)
86
+ const exp = now + options.expiresInSeconds
87
+ const sig = await crypto.subtle.sign(
88
+ "HMAC",
89
+ await importKey(secret),
90
+ ENC.encode(canonical(u, exp)),
91
+ )
92
+ u.searchParams.set("vexp", String(exp))
93
+ u.searchParams.set("vsig", toBase64Url(sig))
94
+ return u.pathname + u.search
95
+ }
96
+
97
+ /** Verify a URL produced by {@link signDownloadUrl}: signature (constant-time) + not expired. */
98
+ export async function verifyDownloadUrl(
99
+ url: string,
100
+ secret: string,
101
+ options?: { readonly now?: number },
102
+ ): Promise<boolean> {
103
+ const u = new URL(url, DUMMY_BASE)
104
+ const exp = getSingleParam(u, "vexp")
105
+ const sig = getSingleParam(u, "vsig")
106
+ if (exp === null || sig === null || !/^\d+$/.test(exp)) return false
107
+ const now = options?.now ?? Math.floor(Date.now() / 1000)
108
+ if (Number(exp) < now) return false
109
+ const sigBytes = fromBase64Url(sig)
110
+ if (sigBytes === null) return false
111
+ return crypto.subtle.verify(
112
+ "HMAC",
113
+ await importKey(secret),
114
+ sigBytes,
115
+ ENC.encode(canonical(u, Number(exp))),
116
+ )
117
+ }
package/src/strip.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Strip EXIF/GPS/metadata from an uploaded image by **re-encoding** it — decoders don't carry
3
+ * embedded metadata through, so a decode→encode round-trip drops it (and normalizes the format).
4
+ *
5
+ * Backend-agnostic: pass any `@nifrajs/image` backend (`bunImageBackend`, `sharpImageBackend`,
6
+ * `wasmImageBackend`) — the structural type below matches them, so this package takes **no** dependency
7
+ * on `@nifrajs/image`. Run server-side (the codecs are Node/Bun-only); validate the upload first
8
+ * (`validateUpload`) so you only re-encode real images.
9
+ */
10
+
11
+ type StripFormat = "webp" | "jpeg" | "png"
12
+
13
+ /** The slice of `@nifrajs/image`'s `ImageBackend` this needs — `probe` for dims/format, `transform` to re-encode. */
14
+ export interface ImageReencoder {
15
+ probe(
16
+ bytes: Uint8Array,
17
+ ): Promise<{ readonly width: number; readonly height: number; readonly format: string }>
18
+ transform(input: {
19
+ readonly bytes: Uint8Array
20
+ readonly width: number
21
+ readonly quality: number
22
+ readonly format: StripFormat
23
+ }): Promise<{ readonly bytes: Uint8Array }>
24
+ }
25
+
26
+ export interface StripImageMetadataOptions {
27
+ /** Output format. Default: keep png/jpeg, else re-encode to webp. */
28
+ readonly format?: StripFormat
29
+ /** Encode quality (1–100). Default 82. */
30
+ readonly quality?: number
31
+ }
32
+
33
+ /** Re-encode an image to its intrinsic size, dropping all embedded metadata. Returns clean bytes. */
34
+ export async function stripImageMetadata(
35
+ bytes: Uint8Array,
36
+ backend: ImageReencoder,
37
+ options: StripImageMetadataOptions = {},
38
+ ): Promise<Uint8Array> {
39
+ const probed = await backend.probe(bytes)
40
+ const format: StripFormat =
41
+ options.format ?? (probed.format === "png" ? "png" : probed.format === "jpeg" ? "jpeg" : "webp")
42
+ const out = await backend.transform({
43
+ bytes,
44
+ width: probed.width,
45
+ quality: options.quality ?? 82,
46
+ format,
47
+ })
48
+ return out.bytes
49
+ }
@@ -0,0 +1,55 @@
1
+ import { detectFileType, type FileType } from "./detect.ts"
2
+
3
+ /**
4
+ * Validate an upload's **size** and **real type** (by magic bytes, not the client's `Content-Type`).
5
+ * Pair with `c.boundedBody(maxBytes)` from `@nifrajs/core` to bound the *read* itself: read the body
6
+ * under the cap, then `validateUpload(bytes, …)` to confirm the size + sniff the type.
7
+ */
8
+
9
+ export type UploadResult =
10
+ | { readonly ok: true; readonly mime: string; readonly ext: string; readonly bytes: Uint8Array }
11
+ | {
12
+ readonly ok: false
13
+ readonly reason: "too_large" | "empty" | "unrecognized" | "type_not_allowed"
14
+ /** The sniffed type, when one was detected (set for `type_not_allowed`). */
15
+ readonly detected?: FileType
16
+ }
17
+
18
+ export interface ValidateUploadOptions {
19
+ /** Max byte length. A `Blob` over this is rejected without reading it. */
20
+ readonly maxBytes: number
21
+ /** Allowed MIME types — exact (`"image/png"`) or a `image/*` wildcard. Omit ⇒ any recognized type. */
22
+ readonly accept?: readonly string[]
23
+ }
24
+
25
+ const matchesAccept = (mime: string, pattern: string): boolean =>
26
+ pattern === mime || (pattern.endsWith("/*") && mime.startsWith(pattern.slice(0, -1)))
27
+
28
+ /** Validate uploaded bytes/Blob: size cap + magic-byte type sniff against an optional allow-list. */
29
+ export async function validateUpload(
30
+ input: Uint8Array | ArrayBuffer | Blob,
31
+ options: ValidateUploadOptions,
32
+ ): Promise<UploadResult> {
33
+ // Reject an oversized Blob by its declared size before buffering it into memory.
34
+ if (input instanceof Blob && input.size > options.maxBytes)
35
+ return { ok: false, reason: "too_large" }
36
+ const bytes =
37
+ input instanceof Uint8Array
38
+ ? input
39
+ : input instanceof ArrayBuffer
40
+ ? new Uint8Array(input)
41
+ : new Uint8Array(await input.arrayBuffer())
42
+
43
+ if (bytes.byteLength > options.maxBytes) return { ok: false, reason: "too_large" }
44
+ if (bytes.byteLength === 0) return { ok: false, reason: "empty" }
45
+
46
+ const detected = detectFileType(bytes)
47
+ if (detected === null) return { ok: false, reason: "unrecognized" }
48
+ if (
49
+ options.accept !== undefined &&
50
+ !options.accept.some((p) => matchesAccept(detected.mime, p))
51
+ ) {
52
+ return { ok: false, reason: "type_not_allowed", detected }
53
+ }
54
+ return { ok: true, mime: detected.mime, ext: detected.ext, bytes }
55
+ }