@arraypress/storage 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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +31 -0
- package/src/index.d.ts +192 -0
- package/src/index.js +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Sherlock / ArrayPress Limited
|
|
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,124 @@
|
|
|
1
|
+
# @arraypress/storage
|
|
2
|
+
|
|
3
|
+
Storage interface, types, and helper utilities for portable file storage across R2, S3, and local filesystem.
|
|
4
|
+
|
|
5
|
+
Works in any JavaScript runtime (Cloudflare Workers, Node.js, Deno, Bun, browsers).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @arraypress/storage
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
This package defines the `Storage` interface that all adapter packages implement. You typically don't use this package directly -- instead, install one of the adapters:
|
|
16
|
+
|
|
17
|
+
| Adapter | Backend |
|
|
18
|
+
|---------|---------|
|
|
19
|
+
| `@arraypress/storage-r2` | Cloudflare R2 native bindings |
|
|
20
|
+
| `@arraypress/storage-s3` | AWS S3, R2 via S3 API, MinIO |
|
|
21
|
+
| `@arraypress/storage-local` | Local filesystem (Node.js) |
|
|
22
|
+
|
|
23
|
+
### Storage Interface
|
|
24
|
+
|
|
25
|
+
Every adapter returns an object implementing this interface:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
interface Storage {
|
|
29
|
+
upload(options: UploadOptions): Promise<UploadResult>;
|
|
30
|
+
download(key: string): Promise<DownloadResult | null>;
|
|
31
|
+
delete(key: string): Promise<void>;
|
|
32
|
+
exists(key: string): Promise<boolean>;
|
|
33
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
34
|
+
getSignedDownloadUrl(options: SignedUrlOptions): Promise<SignedUrl>;
|
|
35
|
+
getSignedUploadUrl(options: SignedUploadUrlOptions): Promise<SignedUrl>;
|
|
36
|
+
getPublicUrl(key: string): string;
|
|
37
|
+
createMultipartUpload(key: string, options?: MultipartUploadOptions): Promise<MultipartUpload>;
|
|
38
|
+
resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Helpers
|
|
43
|
+
|
|
44
|
+
The package also exports helper utilities for common storage operations:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { contentHash, contentAddressedKey, safeDisposition, StorageError } from '@arraypress/storage';
|
|
48
|
+
|
|
49
|
+
// SHA-256 content hashing for deduplication
|
|
50
|
+
const hash = await contentHash(fileBuffer);
|
|
51
|
+
// => 'a1b2c3d4e5f6...'
|
|
52
|
+
|
|
53
|
+
// Generate content-addressed storage keys
|
|
54
|
+
const key = contentAddressedKey(hash, 'photo.jpg', 'media/');
|
|
55
|
+
// => 'media/a1b2c3d4e5f6.jpg'
|
|
56
|
+
|
|
57
|
+
// Determine safe Content-Disposition for downloads
|
|
58
|
+
safeDisposition('image/jpeg'); // => 'inline'
|
|
59
|
+
safeDisposition('image/svg+xml'); // => 'attachment' (XSS risk)
|
|
60
|
+
safeDisposition('application/pdf'); // => 'inline'
|
|
61
|
+
safeDisposition('text/html'); // => 'attachment'
|
|
62
|
+
|
|
63
|
+
// Typed storage errors
|
|
64
|
+
throw new StorageError('File not found', 'NOT_FOUND');
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Content-Addressed Uploads
|
|
68
|
+
|
|
69
|
+
Combine `contentHash` and `contentAddressedKey` to skip duplicate uploads:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { contentHash, contentAddressedKey } from '@arraypress/storage';
|
|
73
|
+
|
|
74
|
+
async function uploadDeduped(storage, file, originalName) {
|
|
75
|
+
const hash = await contentHash(file);
|
|
76
|
+
const key = contentAddressedKey(hash, originalName, 'uploads/');
|
|
77
|
+
|
|
78
|
+
// Skip upload if identical file already exists
|
|
79
|
+
if (await storage.exists(key)) {
|
|
80
|
+
return { key, skipped: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await storage.upload({
|
|
84
|
+
key,
|
|
85
|
+
body: file,
|
|
86
|
+
contentType: 'application/octet-stream',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return { key: result.key, skipped: false };
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API Reference
|
|
94
|
+
|
|
95
|
+
### Types
|
|
96
|
+
|
|
97
|
+
- `Storage` -- Main storage interface implemented by all adapters
|
|
98
|
+
- `UploadOptions` -- Options for `upload()`: `key`, `body`, `contentType`, `metadata?`
|
|
99
|
+
- `UploadResult` -- Result from `upload()`: `key`, `size`, `etag?`
|
|
100
|
+
- `DownloadResult` -- Result from `download()`: `body`, `contentType`, `size`, `etag?`
|
|
101
|
+
- `ListOptions` -- Options for `list()`: `prefix?`, `limit?`, `cursor?`
|
|
102
|
+
- `ListResult` -- Result from `list()`: `objects`, `truncated`, `cursor?`
|
|
103
|
+
- `ListObject` -- Individual object in list: `key`, `size`, `lastModified?`, `etag?`
|
|
104
|
+
- `SignedUrlOptions` -- Options for signed URLs: `key`, `expiresIn?`
|
|
105
|
+
- `SignedUploadUrlOptions` -- Extends `SignedUrlOptions` with `contentType`
|
|
106
|
+
- `SignedUrl` -- Signed URL result: `url`, `expiresAt`
|
|
107
|
+
- `MultipartUploadOptions` -- Options for multipart: `contentType?`, `metadata?`
|
|
108
|
+
- `MultipartUpload` -- Multipart upload handle: `uploadId`, `key`, `uploadPart()`, `complete()`, `abort()`
|
|
109
|
+
- `MultipartPart` -- Completed part: `partNumber`, `etag`
|
|
110
|
+
- `StorageErrorCode` -- `'NOT_FOUND' | 'NOT_SUPPORTED' | 'PERMISSION_DENIED' | 'ALREADY_EXISTS' | 'UNKNOWN'`
|
|
111
|
+
|
|
112
|
+
### Classes
|
|
113
|
+
|
|
114
|
+
- `StorageError` -- Error class with a `code` property (`StorageErrorCode`)
|
|
115
|
+
|
|
116
|
+
### Functions
|
|
117
|
+
|
|
118
|
+
- `contentHash(body)` -- Compute SHA-256 hash of a body. Returns lowercase hex string. Uses Web Crypto API.
|
|
119
|
+
- `contentAddressedKey(hash, originalName, prefix?)` -- Generate a storage key from a hash and original filename, preserving the file extension.
|
|
120
|
+
- `safeDisposition(mimeType)` -- Returns `'inline'` for safe types (images, audio, video, PDF) or `'attachment'` for everything else (SVG, HTML, etc.).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arraypress/storage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Storage interface + types + helpers for portable file storage across R2, S3, and local filesystem",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"storage",
|
|
19
|
+
"r2",
|
|
20
|
+
"s3",
|
|
21
|
+
"file-storage",
|
|
22
|
+
"cloudflare",
|
|
23
|
+
"interface"
|
|
24
|
+
],
|
|
25
|
+
"author": "ArrayPress",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/arraypress/storage"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// === Upload ===
|
|
2
|
+
|
|
3
|
+
export interface UploadOptions {
|
|
4
|
+
/** Storage key (path) for the object. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** File body — accepts any standard web body type. */
|
|
7
|
+
body: ReadableStream | ArrayBuffer | Uint8Array | Blob;
|
|
8
|
+
/** MIME content type. */
|
|
9
|
+
contentType: string;
|
|
10
|
+
/** Optional key-value metadata stored alongside the object. */
|
|
11
|
+
metadata?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UploadResult {
|
|
15
|
+
/** The key the object was stored under. */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Object size in bytes. */
|
|
18
|
+
size: number;
|
|
19
|
+
/** ETag if provided by the backend. */
|
|
20
|
+
etag?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// === Download ===
|
|
24
|
+
|
|
25
|
+
export interface DownloadResult {
|
|
26
|
+
/** Object body as a readable stream. */
|
|
27
|
+
body: ReadableStream;
|
|
28
|
+
/** MIME content type. */
|
|
29
|
+
contentType: string;
|
|
30
|
+
/** Object size in bytes. */
|
|
31
|
+
size: number;
|
|
32
|
+
/** ETag if provided by the backend. */
|
|
33
|
+
etag?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// === List ===
|
|
37
|
+
|
|
38
|
+
export interface ListOptions {
|
|
39
|
+
/** Filter objects by key prefix. */
|
|
40
|
+
prefix?: string;
|
|
41
|
+
/** Maximum number of objects to return. */
|
|
42
|
+
limit?: number;
|
|
43
|
+
/** Pagination cursor from a previous list call. */
|
|
44
|
+
cursor?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ListResult {
|
|
48
|
+
/** Matching objects. */
|
|
49
|
+
objects: ListObject[];
|
|
50
|
+
/** Whether there are more results beyond this page. */
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
/** Cursor for the next page, if truncated. */
|
|
53
|
+
cursor?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ListObject {
|
|
57
|
+
/** Object key (path). */
|
|
58
|
+
key: string;
|
|
59
|
+
/** Object size in bytes. */
|
|
60
|
+
size: number;
|
|
61
|
+
/** Last modified date, if available. */
|
|
62
|
+
lastModified?: Date;
|
|
63
|
+
/** ETag if available. */
|
|
64
|
+
etag?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// === Signed URLs ===
|
|
68
|
+
|
|
69
|
+
export interface SignedUrlOptions {
|
|
70
|
+
/** Object key. */
|
|
71
|
+
key: string;
|
|
72
|
+
/** Expiration time in seconds. Default: 3600. */
|
|
73
|
+
expiresIn?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SignedUploadUrlOptions extends SignedUrlOptions {
|
|
77
|
+
/** Content type for the upload. */
|
|
78
|
+
contentType: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SignedUrl {
|
|
82
|
+
/** The signed URL. */
|
|
83
|
+
url: string;
|
|
84
|
+
/** When the URL expires. */
|
|
85
|
+
expiresAt: Date;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// === Multipart Upload ===
|
|
89
|
+
|
|
90
|
+
export interface MultipartUploadOptions {
|
|
91
|
+
/** MIME content type. */
|
|
92
|
+
contentType?: string;
|
|
93
|
+
/** Optional metadata. */
|
|
94
|
+
metadata?: Record<string, string>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface MultipartUpload {
|
|
98
|
+
/** Backend-assigned upload ID. */
|
|
99
|
+
uploadId: string;
|
|
100
|
+
/** Object key. */
|
|
101
|
+
key: string;
|
|
102
|
+
/** Upload a single part. Part numbers start at 1. */
|
|
103
|
+
uploadPart(partNumber: number, body: ArrayBuffer | Uint8Array): Promise<MultipartPart>;
|
|
104
|
+
/** Finalise the upload with all parts. */
|
|
105
|
+
complete(parts: MultipartPart[]): Promise<void>;
|
|
106
|
+
/** Cancel the upload and discard uploaded parts. */
|
|
107
|
+
abort(): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface MultipartPart {
|
|
111
|
+
/** Part number (1-based). */
|
|
112
|
+
partNumber: number;
|
|
113
|
+
/** ETag returned by the backend for this part. */
|
|
114
|
+
etag: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// === Storage Interface ===
|
|
118
|
+
|
|
119
|
+
export interface Storage {
|
|
120
|
+
/** Upload an object. */
|
|
121
|
+
upload(options: UploadOptions): Promise<UploadResult>;
|
|
122
|
+
|
|
123
|
+
/** Download an object. Returns null if not found. */
|
|
124
|
+
download(key: string): Promise<DownloadResult | null>;
|
|
125
|
+
|
|
126
|
+
/** Delete an object. Does not throw if the key doesn't exist. */
|
|
127
|
+
delete(key: string): Promise<void>;
|
|
128
|
+
|
|
129
|
+
/** Check whether an object exists. */
|
|
130
|
+
exists(key: string): Promise<boolean>;
|
|
131
|
+
|
|
132
|
+
/** List objects, optionally filtered by prefix. */
|
|
133
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get a signed URL for downloading an object.
|
|
137
|
+
* Throws StorageError with code NOT_SUPPORTED if the backend doesn't support signed URLs.
|
|
138
|
+
*/
|
|
139
|
+
getSignedDownloadUrl(options: SignedUrlOptions): Promise<SignedUrl>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get a signed URL for uploading an object directly.
|
|
143
|
+
* Throws StorageError with code NOT_SUPPORTED if the backend doesn't support signed URLs.
|
|
144
|
+
*/
|
|
145
|
+
getSignedUploadUrl(options: SignedUploadUrlOptions): Promise<SignedUrl>;
|
|
146
|
+
|
|
147
|
+
/** Get the public URL for an object. Requires the bucket to be publicly accessible. */
|
|
148
|
+
getPublicUrl(key: string): string;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start a multipart upload for large files.
|
|
152
|
+
* Throws StorageError with code NOT_SUPPORTED if the backend doesn't support multipart uploads.
|
|
153
|
+
*/
|
|
154
|
+
createMultipartUpload(key: string, options?: MultipartUploadOptions): Promise<MultipartUpload>;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resume an in-progress multipart upload.
|
|
158
|
+
* Throws StorageError with code NOT_SUPPORTED if the backend doesn't support multipart uploads.
|
|
159
|
+
*/
|
|
160
|
+
resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// === Error ===
|
|
164
|
+
|
|
165
|
+
export type StorageErrorCode =
|
|
166
|
+
| 'NOT_FOUND'
|
|
167
|
+
| 'NOT_SUPPORTED'
|
|
168
|
+
| 'PERMISSION_DENIED'
|
|
169
|
+
| 'ALREADY_EXISTS'
|
|
170
|
+
| 'UNKNOWN';
|
|
171
|
+
|
|
172
|
+
export declare class StorageError extends Error {
|
|
173
|
+
code: StorageErrorCode;
|
|
174
|
+
constructor(message: string, code: StorageErrorCode);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// === Helpers ===
|
|
178
|
+
|
|
179
|
+
/** Compute SHA-256 hash of a body. Returns lowercase hex string. Uses Web Crypto API. */
|
|
180
|
+
export function contentHash(body: ArrayBuffer | Uint8Array | ReadableStream | Blob): Promise<string>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Generate a content-addressed storage key from a hash and original filename.
|
|
184
|
+
* Example: contentAddressedKey('abc123', 'photo.jpg', 'media/') → 'media/abc123.jpg'
|
|
185
|
+
*/
|
|
186
|
+
export function contentAddressedKey(hash: string, originalName: string, prefix?: string): string;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Determine the safe Content-Disposition for a given MIME type.
|
|
190
|
+
* Safe types (images, audio, video, PDF) → 'inline'; everything else → 'attachment'.
|
|
191
|
+
*/
|
|
192
|
+
export function safeDisposition(mimeType: string): 'inline' | 'attachment';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @arraypress/storage
|
|
3
|
+
*
|
|
4
|
+
* Core storage interface, types, and helper utilities.
|
|
5
|
+
* Zero dependencies — works in any JavaScript runtime.
|
|
6
|
+
*
|
|
7
|
+
* @module @arraypress/storage
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// === Error ===
|
|
11
|
+
|
|
12
|
+
export class StorageError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {import('./index.d.ts').StorageErrorCode} code
|
|
16
|
+
*/
|
|
17
|
+
constructor(message, code = 'UNKNOWN') {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'StorageError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// === Helpers ===
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute SHA-256 hash of a body. Returns lowercase hex string.
|
|
28
|
+
*
|
|
29
|
+
* @param {ArrayBuffer | Uint8Array | ReadableStream | Blob} body
|
|
30
|
+
* @returns {Promise<string>}
|
|
31
|
+
*/
|
|
32
|
+
export async function contentHash(body) {
|
|
33
|
+
let buffer;
|
|
34
|
+
if (body instanceof ArrayBuffer) {
|
|
35
|
+
buffer = body;
|
|
36
|
+
} else if (body instanceof Uint8Array) {
|
|
37
|
+
buffer = body.buffer;
|
|
38
|
+
} else {
|
|
39
|
+
// ReadableStream or Blob — convert via Response
|
|
40
|
+
buffer = await new Response(body).arrayBuffer();
|
|
41
|
+
}
|
|
42
|
+
const hash = await crypto.subtle.digest('SHA-256', buffer);
|
|
43
|
+
return Array.from(new Uint8Array(hash))
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
45
|
+
.join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a content-addressed storage key from a hash and original filename.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} hash - The content hash (e.g. SHA-256 hex).
|
|
52
|
+
* @param {string} originalName - Original filename to extract extension from.
|
|
53
|
+
* @param {string} [prefix=''] - Optional key prefix (e.g. 'media/').
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* contentAddressedKey('abc123', 'photo.jpg', 'media/')
|
|
58
|
+
* // → 'media/abc123.jpg'
|
|
59
|
+
*/
|
|
60
|
+
export function contentAddressedKey(hash, originalName, prefix = '') {
|
|
61
|
+
const dotIndex = originalName.lastIndexOf('.');
|
|
62
|
+
const ext = dotIndex > 0 ? originalName.slice(dotIndex) : '';
|
|
63
|
+
return `${prefix}${hash}${ext}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** MIME types that are safe to serve inline (no XSS risk). */
|
|
67
|
+
const INLINE_TYPES = new Set([
|
|
68
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/tiff',
|
|
69
|
+
'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/flac',
|
|
70
|
+
'video/mp4', 'video/webm', 'video/ogg',
|
|
71
|
+
'application/pdf',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Determine the safe Content-Disposition for a given MIME type.
|
|
76
|
+
* Safe types (images, audio, video, PDF) → 'inline'; everything else → 'attachment'.
|
|
77
|
+
*
|
|
78
|
+
* SVG, HTML, and other scriptable types are forced to 'attachment' to prevent XSS.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} mimeType
|
|
81
|
+
* @returns {'inline' | 'attachment'}
|
|
82
|
+
*/
|
|
83
|
+
export function safeDisposition(mimeType) {
|
|
84
|
+
return INLINE_TYPES.has(mimeType) ? 'inline' : 'attachment';
|
|
85
|
+
}
|