partial-content 1.0.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 (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Content-Disposition header builder (RFC 6266 + RFC 8187).
3
+ *
4
+ * Builds Content-Disposition headers for HTTP responses that serve files.
5
+ * Handles both inline (in-app preview) and attachment (download) dispositions
6
+ * with proper RFC compliance for non-ASCII filenames.
7
+ *
8
+ * Security: Prevents CRLF header injection, path traversal, bidi override
9
+ * spoofing, and control character injection in filenames from untrusted
10
+ * sources (integration APIs, user uploads).
11
+ *
12
+ * Emits dual filename parameters for cross-browser compatibility:
13
+ * - `filename="ascii-safe.pdf"` for legacy browsers
14
+ * - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
15
+ */
16
+
17
+ // ─── Types ──────────────────────────────────────────────────────────────────
18
+
19
+ export interface ContentDispositionOptions {
20
+ /**
21
+ * Disposition type.
22
+ * - `"attachment"`: Force download (safe default for untrusted content)
23
+ * - `"inline"`: Render in browser (only for previewable types: PDF, image, video, audio)
24
+ * @default "attachment"
25
+ */
26
+ type?: "attachment" | "inline";
27
+ /**
28
+ * Fallback filename when input is null, undefined, or empty after sanitization.
29
+ * @default "document"
30
+ */
31
+ fallback?: string;
32
+ }
33
+
34
+ // ─── Constants ──────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * RFC 2616 token characters. A filename that matches this pattern can be
38
+ * emitted unquoted in the `filename=` parameter (e.g. `filename=plans.pdf`).
39
+ * Anything outside this set requires quoted-string encoding.
40
+ *
41
+ * token = 1*<any CHAR except CTLs or separators>
42
+ * separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
43
+ * | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
44
+ */
45
+ const TOKEN_REGEXP = /^[!#$%&'*+.0-9A-Z^_`a-z|~-]+$/;
46
+
47
+ /**
48
+ * Characters that are valid US-ASCII text (printable range 0x20-0x7E).
49
+ * Filenames matching this can use the simple `filename=` parameter with
50
+ * quoted-string encoding. Non-ASCII filenames need `filename*=` (RFC 8187).
51
+ */
52
+ const ASCII_TEXT_REGEXP = /^[\x20-\x7e]*$/;
53
+
54
+ /**
55
+ * Characters that need escaping inside an RFC 2616 quoted-string.
56
+ * Per Section 2.2: quoted-pair = "\" CHAR, and the only characters
57
+ * that MUST be escaped are `\` and `"`.
58
+ */
59
+ const QUOTED_PAIR_REGEXP = /[\\"]/g;
60
+
61
+ /**
62
+ * Characters that are not valid in RFC 8187 attr-char, applied AFTER
63
+ * encodeURIComponent (so `%` is already handled). This catches characters
64
+ * that encodeURIComponent leaves unescaped but RFC 8187 requires encoded.
65
+ */
66
+ const ENCODE_URL_ATTR_CHAR_REGEXP = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; // eslint-disable-line no-control-regex
67
+
68
+ /**
69
+ * Non-US-ASCII characters. Replaced with `?` in the ASCII fallback
70
+ * for maximum compatibility with legacy HTTP clients.
71
+ */
72
+ const NON_ASCII_REGEXP = /[^\x20-\x7e]/g;
73
+
74
+ /**
75
+ * Filenames containing percent-encoded sequences (like `the%20plans.pdf`)
76
+ * should not use the simple `filename=` parameter because legacy clients
77
+ * may decode them. Force `filename*` for these.
78
+ */
79
+ const HEX_ESCAPE_REGEXP = /%[0-9A-Fa-f]{2}/;
80
+
81
+ /**
82
+ * Matches lone surrogate code units (unpaired high or low surrogates).
83
+ * These occur when filenames are truncated mid-emoji with .slice().
84
+ * encodeURIComponent throws URIError on lone surrogates, so we must
85
+ * strip them before encoding.
86
+ */
87
+ const LONE_SURROGATE_REGEXP = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
88
+
89
+ // ─── Public API ─────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Build a Content-Disposition header value with RFC compliance and
93
+ * security sanitization.
94
+ *
95
+ * Accepts untrusted filenames from integration APIs, user uploads, and
96
+ * database records. Sanitizes for:
97
+ * - CRLF injection (strips \r, \n, and all control characters)
98
+ * - Path traversal (strips ../ and ..\\ sequences)
99
+ * - Basename extraction (strips directory paths)
100
+ * - Bidi override stripping (prevents RLO filename spoofing)
101
+ * - Double-quote and backslash escaping (RFC 2616 quoted-pair)
102
+ *
103
+ * Emits dual filename parameters for cross-browser compatibility:
104
+ * - `filename="ascii-safe.pdf"` for legacy browsers (IE, old Safari)
105
+ * - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
106
+ *
107
+ * Token optimization: Simple ASCII filenames like `plans.pdf` are emitted
108
+ * unquoted (`filename=plans.pdf`).
109
+ *
110
+ * @param filename - Raw filename from untrusted source (upload, API, DB)
111
+ * @param options - Disposition type and fallback configuration
112
+ * @returns Complete Content-Disposition header value string
113
+ *
114
+ * @example
115
+ * // Simple ASCII attachment
116
+ * buildContentDisposition("report.pdf")
117
+ * // => 'attachment; filename=report.pdf'
118
+ *
119
+ * @example
120
+ * // Non-ASCII filename (Danish)
121
+ * buildContentDisposition("Årlig_Rapport.pdf")
122
+ * // => 'attachment; filename="?rlig_Rapport.pdf"; filename*=UTF-8\'\'%C3%85rlig_Rapport.pdf'
123
+ *
124
+ * @example
125
+ * // Inline disposition for preview
126
+ * buildContentDisposition("slides.pdf", { type: "inline" })
127
+ * // => 'inline; filename=slides.pdf'
128
+ *
129
+ * @example
130
+ * // Null input with custom fallback
131
+ * buildContentDisposition(null, { fallback: "export.csv" })
132
+ * // => 'attachment; filename=export.csv'
133
+ */
134
+ export function buildContentDisposition(
135
+ filename: string | null | undefined,
136
+ options?: ContentDispositionOptions,
137
+ ): string {
138
+ // Never trust the disposition type: a JS caller (or a mistyped
139
+ // `disposition` extractor returning a computed string) could otherwise
140
+ // smuggle header parameters through it. Anything but "inline" is
141
+ // "attachment" -- the safe default.
142
+ const type = options?.type === "inline" ? "inline" : "attachment";
143
+
144
+ // Sanitize BOTH the untrusted filename and the fallback through the same
145
+ // cleaner, then fall back to a constant. A filename that reduces to
146
+ // nothing (only controls/bidi/path components) must NOT emit a raw
147
+ // fallback into filename* -- that path leaked un-neutralized bidi
148
+ // overrides, defeating the anti-spoofing guarantee.
149
+ const sanitized =
150
+ sanitizeFilename(filename ?? "")
151
+ || sanitizeFilename(options?.fallback ?? "")
152
+ || "document";
153
+
154
+ return `${type}${buildFilenameParams(sanitized)}`;
155
+ }
156
+
157
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Sanitize an untrusted filename for safe use in HTTP headers.
161
+ *
162
+ * Applied BEFORE any RFC encoding. This is the security layer that
163
+ * prevents header injection and path traversal attacks.
164
+ */
165
+ function sanitizeFilename(raw: string): string {
166
+ // 1. Strip control characters and Unicode formatting controls.
167
+ // C0 controls (\x00-\x1F), DEL (\x7F): prevents CRLF header injection.
168
+ // C1 controls (\x80-\x9F): invisible formatting, no valid use in filenames.
169
+ // Bidi overrides (U+202A-202E, U+2066-2069): prevents RLO filename
170
+ // spoofing where "report\u202Efdp.exe" renders as "reportexe.pdf".
171
+ // Zero-width chars (U+200B-200F): invisible joiners/marks, strips
172
+ // directional marks that could confuse filename display.
173
+ // NBSP (U+00A0): often mistaken for regular space, normalize away.
174
+ const noControls = raw.replace(
175
+ /[\r\n\x00-\x1F\x7F\x80-\x9F\u00A0\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g,
176
+ "",
177
+ );
178
+
179
+ // 2. Strip path traversal sequences (../ and ..\).
180
+ // Some integration APIs return full paths or relative paths.
181
+ // Even though HTTP clients shouldn't interpret these, defense-in-depth.
182
+ const noTraversal = noControls.replace(/\.\.[/\\]/g, "");
183
+
184
+ // 3. Extract basename (strip directory components).
185
+ // Integration APIs sometimes return full paths like "/uploads/2024/report.pdf".
186
+ // Uses lastIndexOf instead of split().pop() to avoid array allocation.
187
+ const lastSep = Math.max(noTraversal.lastIndexOf("/"), noTraversal.lastIndexOf("\\"));
188
+ const basename = lastSep >= 0 ? noTraversal.slice(lastSep + 1) : noTraversal;
189
+
190
+ // May be empty: the caller composes the fallback chain so both the
191
+ // filename and the fallback pass through this same sanitizer.
192
+ return basename;
193
+ }
194
+
195
+ /**
196
+ * Build the filename parameter string for Content-Disposition.
197
+ *
198
+ * Encoding strategy:
199
+ * 1. Pure ASCII token (no separators): emit unquoted `filename=report.pdf`
200
+ * 2. ASCII with special chars: emit quoted `filename="my report.pdf"`
201
+ * 3. Non-ASCII: emit both `filename="?rlig.pdf"` and `filename*=UTF-8''...`
202
+ *
203
+ * RFC 2616 Section 2.2 quoted-string escaping:
204
+ * - `\` -> `\\` (backslash must be escaped)
205
+ * - `"` -> `\"` (double-quote must be escaped)
206
+ */
207
+ function buildFilenameParams(filename: string): string {
208
+ // Case 1: Pure ASCII that fits in a token -- emit unquoted.
209
+ // Also reject filenames with hex-encoded sequences (%20) which could
210
+ // be decoded by legacy clients.
211
+ if (ASCII_TEXT_REGEXP.test(filename) && !HEX_ESCAPE_REGEXP.test(filename)) {
212
+ if (TOKEN_REGEXP.test(filename)) {
213
+ return `; filename=${filename}`;
214
+ }
215
+ // ASCII but contains separators/spaces -- use quoted-string
216
+ return `; filename=${quoteString(filename)}`;
217
+ }
218
+
219
+ // Case 2: Contains non-ASCII characters or hex escapes.
220
+ // Emit both parameters for cross-browser compatibility:
221
+ // - filename="ascii-fallback" for legacy clients
222
+ // - filename*=UTF-8''percent-encoded for modern clients (RFC 8187)
223
+ const asciiFallback = toAsciiFallback(filename);
224
+ const encoded = encodeRfc8187(filename);
225
+
226
+ // When filename has hex escapes but is pure ASCII, we still need
227
+ // filename* because the hex escape could be decoded by legacy clients.
228
+ // Percent-encode % in the fallback to prevent unintended decoding.
229
+ const safeFallback = asciiFallback.replace(/%/g, "%25");
230
+ if (TOKEN_REGEXP.test(safeFallback)) {
231
+ return `; filename=${safeFallback}; filename*=${encoded}`;
232
+ }
233
+ return `; filename=${quoteString(safeFallback)}; filename*=${encoded}`;
234
+ }
235
+
236
+ /**
237
+ * Quote a string per RFC 2616 Section 2.2.
238
+ *
239
+ * quoted-string = ( <"> *(qdtext | quoted-pair) <"> )
240
+ * quoted-pair = "\" CHAR
241
+ *
242
+ * Both `\` and `"` MUST be escaped with a preceding backslash.
243
+ *
244
+ * In practice, `\` is stripped by sanitizeFilename (treated as path separator),
245
+ * so only `"` escaping is exercised through the normal code path. The `\`
246
+ * escaping is defense-in-depth for correctness if sanitization ever changes.
247
+ */
248
+ function quoteString(str: string): string {
249
+ return `"${str.replace(QUOTED_PAIR_REGEXP, "\\$&")}"`;
250
+ }
251
+
252
+ /**
253
+ * Generate a US-ASCII fallback for non-ASCII filenames.
254
+ *
255
+ * Replaces non-ASCII characters with `?`. This is more honest than `_` --
256
+ * it signals to the user that characters were lost in translation, rather
257
+ * than suggesting underscores were in the original.
258
+ */
259
+ function toAsciiFallback(filename: string): string {
260
+ return filename.replace(NON_ASCII_REGEXP, "?");
261
+ }
262
+
263
+ /**
264
+ * Encode a string per RFC 8187 (formerly RFC 5987).
265
+ *
266
+ * Format: charset'language'value-chars
267
+ * We always use UTF-8 and leave language empty.
268
+ *
269
+ * The encoding differs from encodeURIComponent in that RFC 8187
270
+ * defines its own set of "attr-char" that don't need encoding.
271
+ * We first apply encodeURIComponent (which handles all non-ASCII),
272
+ * then encode the characters that encodeURIComponent leaves unescaped
273
+ * but RFC 8187 requires encoded (including single quotes, which are
274
+ * the charset/language delimiter in the RFC 8187 format).
275
+ */
276
+ function encodeRfc8187(str: string): string {
277
+ // encodeURIComponent itself throws URIError on lone surrogates
278
+ // (ECMA-262), so the exception IS the surrogate detector: clean strings
279
+ // (the overwhelming majority, including valid emoji pairs) pay no
280
+ // pre-scan at all. Lone surrogates only occur when upstream code
281
+ // truncates filenames mid-emoji with .slice() on UTF-16 code units;
282
+ // that pathological path eats the exception cost and retries stripped.
283
+ // Benchmarked against a surrogate-range pre-scan regex: this is ~6x
284
+ // faster for emoji filenames (a pre-scan cannot tell valid pairs from
285
+ // lone surrogates, so it triggered the strip for every emoji).
286
+ let encoded: string;
287
+ try {
288
+ encoded = encodeURIComponent(str);
289
+ } catch {
290
+ encoded = encodeURIComponent(str.replace(LONE_SURROGATE_REGEXP, ""));
291
+ }
292
+ encoded = encoded.replace(ENCODE_URL_ATTR_CHAR_REGEXP, percentEncode);
293
+
294
+ return `UTF-8''${encoded}`;
295
+ }
296
+
297
+ /** Percent-encode a single character. */
298
+ function percentEncode(char: string): string {
299
+ return "%" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0");
300
+ }