@telestack/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/README.md +132 -0
- package/debug-fetch.js +28 -0
- package/dist/index.d.mts +329 -0
- package/dist/index.d.ts +329 -0
- package/dist/index.global.js +1 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +37 -0
- package/src/BatchBuilder.ts +48 -0
- package/src/CryptoHelper.ts +72 -0
- package/src/HttpClient.ts +152 -0
- package/src/ImageHelper.ts +80 -0
- package/src/QueryBuilder.ts +45 -0
- package/src/StorageRef.ts +153 -0
- package/src/TelestackStorage.ts +93 -0
- package/src/UploadTask.ts +332 -0
- package/src/index.ts +28 -0
- package/src/types.ts +91 -0
- package/test-e2e.js +142 -0
- package/test-e2e.ts +182 -0
- package/test-output.txt +0 -0
- package/tsconfig.json +17 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
declare class HttpClient {
|
|
2
|
+
private baseUrl;
|
|
3
|
+
private tenantId;
|
|
4
|
+
private headers;
|
|
5
|
+
constructor(config: TelestackConfig);
|
|
6
|
+
private _fetchWithRetry;
|
|
7
|
+
get<T>(path: string, params?: Record<string, string | undefined>): Promise<T>;
|
|
8
|
+
post<T>(path: string, body?: unknown): Promise<T>;
|
|
9
|
+
patch<T>(path: string, body?: unknown): Promise<T>;
|
|
10
|
+
put<T>(path: string, body?: unknown): Promise<T>;
|
|
11
|
+
delete<T>(path: string): Promise<T>;
|
|
12
|
+
/** Upload a file binary buffer directly to a presigned S3 URL. */
|
|
13
|
+
uploadToPresignedUrl(url: string, data: Blob | ArrayBuffer, contentType: string, onProgress?: (p: number) => void): Promise<void>;
|
|
14
|
+
private _handle;
|
|
15
|
+
}
|
|
16
|
+
declare class TelestackError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly code?: string | undefined;
|
|
19
|
+
constructor(message: string, status: number, code?: string | undefined);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A cancellable, resumable, and observable upload task.
|
|
24
|
+
* Designed directly after Firebase Storage's UploadTask but optimized for Telestack resumable multipart pipelines.
|
|
25
|
+
*/
|
|
26
|
+
declare class UploadTask implements Promise<UploadResult> {
|
|
27
|
+
private readonly client;
|
|
28
|
+
private readonly path;
|
|
29
|
+
private readonly name;
|
|
30
|
+
private readonly contentType;
|
|
31
|
+
private readonly options;
|
|
32
|
+
private _state;
|
|
33
|
+
private _bytesTransferred;
|
|
34
|
+
private _totalBytes;
|
|
35
|
+
private _data;
|
|
36
|
+
private _observers;
|
|
37
|
+
private _promise;
|
|
38
|
+
private _resolve;
|
|
39
|
+
private _reject;
|
|
40
|
+
private _uploadId?;
|
|
41
|
+
private _parts;
|
|
42
|
+
private _currentPartIndex;
|
|
43
|
+
private _activeXhr?;
|
|
44
|
+
private _isResumable;
|
|
45
|
+
private _simpleFileMetadata?;
|
|
46
|
+
constructor(client: HttpClient, path: string, data: Blob, name: string, contentType: string, options: UploadOptions);
|
|
47
|
+
/** Register observers for state changes, errors, and completion. */
|
|
48
|
+
on(event: 'state_changed', nextOrObserver?: Observer<UploadTaskSnapshot> | ((snap: UploadTaskSnapshot) => void), error?: (err: Error) => void, complete?: () => void): () => void;
|
|
49
|
+
/** Suspend the upload. Only works elegantly on resumable uploads, but supported for all. */
|
|
50
|
+
pause(): boolean;
|
|
51
|
+
/** Resume a paused upload. */
|
|
52
|
+
resume(): boolean;
|
|
53
|
+
/** Permanently cancel the upload, cleaning up partial server state if necessary. */
|
|
54
|
+
cancel(): boolean;
|
|
55
|
+
get snapshot(): UploadTaskSnapshot;
|
|
56
|
+
then<TResult1 = UploadResult, TResult2 = never>(onfulfilled?: ((value: UploadResult) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null): Promise<TResult1 | TResult2>;
|
|
57
|
+
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null): Promise<UploadResult | TResult>;
|
|
58
|
+
finally(onfinally?: (() => void) | null): Promise<UploadResult>;
|
|
59
|
+
readonly [Symbol.toStringTag] = "UploadTask";
|
|
60
|
+
private _start;
|
|
61
|
+
private _startSimple;
|
|
62
|
+
private _startResumable;
|
|
63
|
+
private _continueResumable;
|
|
64
|
+
private _uploadChunkWithProgress;
|
|
65
|
+
private _notifyObservers;
|
|
66
|
+
private _handleError;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TelestackConfig {
|
|
70
|
+
baseUrl: string;
|
|
71
|
+
apiKey?: string;
|
|
72
|
+
token?: string;
|
|
73
|
+
tenantId: string;
|
|
74
|
+
defaultUploadOptions?: Partial<UploadOptions>;
|
|
75
|
+
}
|
|
76
|
+
interface FileMetadata {
|
|
77
|
+
id: string;
|
|
78
|
+
tenant_id: string;
|
|
79
|
+
path: string;
|
|
80
|
+
name: string;
|
|
81
|
+
size: number;
|
|
82
|
+
content_type: string;
|
|
83
|
+
owner_id: string;
|
|
84
|
+
status: 'pending' | 'active' | 'deleted';
|
|
85
|
+
metadata: Record<string, any>;
|
|
86
|
+
created_at: string;
|
|
87
|
+
updated_at: string;
|
|
88
|
+
}
|
|
89
|
+
interface UploadOptions {
|
|
90
|
+
tenantId?: string;
|
|
91
|
+
userId: string;
|
|
92
|
+
metadata?: Record<string, any>;
|
|
93
|
+
chunkSize?: number;
|
|
94
|
+
encryptionKey?: string;
|
|
95
|
+
compressImage?: {
|
|
96
|
+
maxWidth?: number;
|
|
97
|
+
maxHeight?: number;
|
|
98
|
+
quality?: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
interface DownloadOptions {
|
|
102
|
+
versionId?: string;
|
|
103
|
+
}
|
|
104
|
+
interface ListOptions {
|
|
105
|
+
prefix?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
}
|
|
108
|
+
interface SearchOptions {
|
|
109
|
+
query?: string;
|
|
110
|
+
metadata?: Record<string, any>;
|
|
111
|
+
limit?: number;
|
|
112
|
+
}
|
|
113
|
+
interface SearchResult {
|
|
114
|
+
success: boolean;
|
|
115
|
+
files: FileMetadata[];
|
|
116
|
+
}
|
|
117
|
+
interface UploadResult {
|
|
118
|
+
file: FileMetadata;
|
|
119
|
+
resumable: boolean;
|
|
120
|
+
}
|
|
121
|
+
interface BatchDeleteResult {
|
|
122
|
+
success: boolean;
|
|
123
|
+
deletedCount: number;
|
|
124
|
+
errors: any[];
|
|
125
|
+
}
|
|
126
|
+
interface BatchCopyResult {
|
|
127
|
+
success: boolean;
|
|
128
|
+
results: any[];
|
|
129
|
+
}
|
|
130
|
+
interface TagSet {
|
|
131
|
+
Key: string;
|
|
132
|
+
Value: string;
|
|
133
|
+
}
|
|
134
|
+
/** Upload State Enum exactly like Firebase */
|
|
135
|
+
type UploadState = 'processing' | 'running' | 'paused' | 'success' | 'error' | 'canceled';
|
|
136
|
+
interface UploadTaskSnapshot {
|
|
137
|
+
bytesTransferred: number;
|
|
138
|
+
totalBytes: number;
|
|
139
|
+
state: UploadState;
|
|
140
|
+
task: UploadTask;
|
|
141
|
+
}
|
|
142
|
+
type Observer<T> = {
|
|
143
|
+
next?: (value: T) => void;
|
|
144
|
+
error?: (error: Error) => void;
|
|
145
|
+
complete?: () => void;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Base abstract reference to a location in Telestack Storage.
|
|
150
|
+
*/
|
|
151
|
+
declare abstract class StorageRef {
|
|
152
|
+
protected readonly client: HttpClient;
|
|
153
|
+
readonly path: string;
|
|
154
|
+
readonly tenantId: string;
|
|
155
|
+
constructor(client: HttpClient, path: string, tenantId: string);
|
|
156
|
+
/** Navigate to a highly specific child path. Automatically infers File or Directory Ref. */
|
|
157
|
+
child(childPath: string): StorageRef;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reference exclusively to a Directory (Prefix).
|
|
161
|
+
*/
|
|
162
|
+
declare class DirRef extends StorageRef {
|
|
163
|
+
/** Retrieve all files immediately within this directory prefix. */
|
|
164
|
+
listAll(limit?: number): Promise<FileMetadata[]>;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reference exclusively to a File object.
|
|
168
|
+
*/
|
|
169
|
+
declare class FileRef extends StorageRef {
|
|
170
|
+
/**
|
|
171
|
+
* Upload a File/Blob, returning an UploadTask that can be observed, paused, and cancelled.
|
|
172
|
+
* Functions identically to Firebase Storage.
|
|
173
|
+
*/
|
|
174
|
+
put(data: File | Blob, options: UploadOptions): UploadTask;
|
|
175
|
+
/**
|
|
176
|
+
* Upload raw bytes directly.
|
|
177
|
+
*/
|
|
178
|
+
putBytes(data: ArrayBuffer, contentType: string, options: UploadOptions): UploadTask;
|
|
179
|
+
/** Get a time-limited presigned download URL. */
|
|
180
|
+
getDownloadUrl(options?: DownloadOptions): Promise<string>;
|
|
181
|
+
/**
|
|
182
|
+
* Securely downloads and decrypts an E2EE file entirely within the browser.
|
|
183
|
+
* Requires the exact Base64 AES-GCM key used during `put()`.
|
|
184
|
+
*/
|
|
185
|
+
getDecryptedBlob(encryptionKey: string): Promise<Blob>;
|
|
186
|
+
/** Get the file's metadata database record. */
|
|
187
|
+
getMetadata(): Promise<FileMetadata>;
|
|
188
|
+
/** Update custom JSON metadata on the file. */
|
|
189
|
+
updateMetadata(metadata: Record<string, any>): Promise<FileMetadata>;
|
|
190
|
+
/** Permanently delete this file. */
|
|
191
|
+
delete(): Promise<void>;
|
|
192
|
+
listVersions(): Promise<{
|
|
193
|
+
versions: any[];
|
|
194
|
+
deleteMarkers: any[];
|
|
195
|
+
}>;
|
|
196
|
+
getVersionUrl(versionId: string): Promise<string>;
|
|
197
|
+
getTags(): Promise<TagSet[]>;
|
|
198
|
+
setTags(tags: TagSet[]): Promise<void>;
|
|
199
|
+
setLegalHold(status: 'ON' | 'OFF'): Promise<void>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fluent builder for batch file operations.
|
|
204
|
+
* Operations execute identically as single network requests over the backend API.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* await storage.batch().delete(['a.pdf', 'b.pdf']).run();
|
|
208
|
+
* await storage.batch().copy(['a.pdf'], 'archive/').run();
|
|
209
|
+
* await storage.batch().move(['a.pdf'], 'archive/').run();
|
|
210
|
+
*/
|
|
211
|
+
declare class BatchBuilder {
|
|
212
|
+
private readonly client;
|
|
213
|
+
private _op;
|
|
214
|
+
private _paths;
|
|
215
|
+
private _dest;
|
|
216
|
+
constructor(client: HttpClient);
|
|
217
|
+
delete(paths: string[]): this;
|
|
218
|
+
copy(paths: string[], destinationPrefix: string): this;
|
|
219
|
+
move(paths: string[], destinationPrefix: string): this;
|
|
220
|
+
run(): Promise<BatchDeleteResult | BatchCopyResult>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Fluent builder for searching files via Metadata or Full-Text queries.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* const files = await storage.query()
|
|
228
|
+
* .where('project', 'Q1')
|
|
229
|
+
* .nameContains('report')
|
|
230
|
+
* .limit(10)
|
|
231
|
+
* .get();
|
|
232
|
+
*/
|
|
233
|
+
declare class QueryBuilder {
|
|
234
|
+
private readonly client;
|
|
235
|
+
private _query;
|
|
236
|
+
private _metadata;
|
|
237
|
+
private _limit;
|
|
238
|
+
constructor(client: HttpClient);
|
|
239
|
+
nameContains(text: string): this;
|
|
240
|
+
where(key: string, value: any): this;
|
|
241
|
+
limit(n: number): this;
|
|
242
|
+
get(): Promise<FileMetadata[]>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* TelestackStorage — The main SDK client.
|
|
247
|
+
* More comprehensive and fluent than Firebase/Appwrite Storage.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* const storage = new TelestackStorage({
|
|
252
|
+
* baseUrl: 'https://storage.yourdomain.com',
|
|
253
|
+
* tenantId: 'your_tenant_id',
|
|
254
|
+
* apiKey: 'tk_your_api_key',
|
|
255
|
+
* });
|
|
256
|
+
*
|
|
257
|
+
* // Upload with progress, pause, and resume
|
|
258
|
+
* const task = storage.ref('videos/demo.mp4').put(videoBlob);
|
|
259
|
+
* task.on('state_changed',
|
|
260
|
+
* (snap) => console.log((snap.bytesTransferred / snap.totalBytes) * 100 + '%'),
|
|
261
|
+
* (err) => console.error(err),
|
|
262
|
+
* () => console.log('Done!')
|
|
263
|
+
* );
|
|
264
|
+
*
|
|
265
|
+
* // Later...
|
|
266
|
+
* task.pause();
|
|
267
|
+
* task.resume();
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
declare class TelestackStorage {
|
|
271
|
+
private readonly config;
|
|
272
|
+
private readonly http;
|
|
273
|
+
readonly tenantId: string;
|
|
274
|
+
constructor(config: TelestackConfig);
|
|
275
|
+
/** Get a highly-fluent reference to a specific file. */
|
|
276
|
+
ref(path: string): FileRef;
|
|
277
|
+
/** Get a highly-fluent reference to a directory/prefix. */
|
|
278
|
+
dir(path: string): DirRef;
|
|
279
|
+
/** Helper: List files for the entire bucket/tenant or specific prefix. */
|
|
280
|
+
list(options?: ListOptions): Promise<FileMetadata[]>;
|
|
281
|
+
/** Get a fluent batch operation builder for mass-mutations over the network. */
|
|
282
|
+
batch(): BatchBuilder;
|
|
283
|
+
/** Get a fluent query builder for metadata and full-text search. */
|
|
284
|
+
query(): QueryBuilder;
|
|
285
|
+
/** Generate a new API key (admin only). The key is shown only once. */
|
|
286
|
+
generateApiKey(name?: string): Promise<{
|
|
287
|
+
apiKey: string;
|
|
288
|
+
message: string;
|
|
289
|
+
}>;
|
|
290
|
+
/** Revoke an API key by its ID. */
|
|
291
|
+
revokeApiKey(keyId: string): Promise<void>;
|
|
292
|
+
/** Get S3 bucket configuration details. */
|
|
293
|
+
getBucketInfo(): Promise<any>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
declare class CryptoHelper {
|
|
297
|
+
/**
|
|
298
|
+
* Generates a new random AES-GCM 256-bit encryption key.
|
|
299
|
+
* Returns the key as a base64 encoded string.
|
|
300
|
+
*/
|
|
301
|
+
static generateKey(): Promise<string>;
|
|
302
|
+
/**
|
|
303
|
+
* Encrypts a Blob using AES-GCM.
|
|
304
|
+
* Prepends a random 12-byte IV to the resulting ciphertext Blob.
|
|
305
|
+
*/
|
|
306
|
+
static encrypt(blob: Blob, keyBase64: string): Promise<Blob>;
|
|
307
|
+
/**
|
|
308
|
+
* Decrypts a Blob that was encrypted with `encrypt()`.
|
|
309
|
+
* Extracts the 12-byte IV from the front and decrypts the rest.
|
|
310
|
+
* Restores the original MIME type.
|
|
311
|
+
*/
|
|
312
|
+
static decrypt(blob: Blob, keyBase64: string, originalMimeType?: string): Promise<Blob>;
|
|
313
|
+
private static _importKey;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
declare class ImageHelper {
|
|
317
|
+
/**
|
|
318
|
+
* Compresses and resizes an image Blob using the browser's native HTML5 Canvas.
|
|
319
|
+
* Only processes blobs where `type` starts with 'image/'.
|
|
320
|
+
* If it's not an image, it returns the original blob untouched.
|
|
321
|
+
*/
|
|
322
|
+
static compress(blob: Blob, options?: {
|
|
323
|
+
maxWidth?: number;
|
|
324
|
+
maxHeight?: number;
|
|
325
|
+
quality?: number;
|
|
326
|
+
}): Promise<Blob>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export { BatchBuilder, type BatchCopyResult, type BatchDeleteResult, CryptoHelper, DirRef, type DownloadOptions, type FileMetadata, FileRef, HttpClient, ImageHelper, type ListOptions, type Observer, QueryBuilder, type SearchOptions, type SearchResult, StorageRef, type TagSet, type TelestackConfig, TelestackError, TelestackStorage, type UploadOptions, type UploadResult, type UploadState, UploadTask, type UploadTaskSnapshot };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var TelestackStorageSetup=(()=>{var U=Object.defineProperty;var B=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var S=(l,t)=>{for(var e in t)U(l,e,{get:t[e],enumerable:!0})},M=(l,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of x(t))!C.call(l,r)&&r!==e&&U(l,r,{get:()=>t[r],enumerable:!(s=B(t,r))||s.enumerable});return l};var E=l=>M(U({},"__esModule",{value:!0}),l);var O={};S(O,{BatchBuilder:()=>b,CryptoHelper:()=>d,DirRef:()=>y,FileRef:()=>f,HttpClient:()=>_,ImageHelper:()=>g,QueryBuilder:()=>v,StorageRef:()=>w,TelestackError:()=>u,TelestackStorage:()=>T,UploadTask:()=>m});var _=class{baseUrl;tenantId;headers;constructor(t){this.baseUrl=t.baseUrl.replace(/\/$/,""),this.tenantId=t.tenantId,this.headers={"Content-Type":"application/json","X-Tenant-ID":t.tenantId},t.apiKey?this.headers["X-API-Key"]=t.apiKey:t.token&&(this.headers.Authorization=`Bearer ${t.token}`)}async _fetchWithRetry(t,e,s=3){for(let r=0;r<=s;r++)try{let i=await fetch(t,e);if((i.status===429||i.status>=500)&&r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network error ${i.status}. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}return i}catch(i){if(r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network un-reachable. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}throw i}throw new Error("Unreachable code in retry logic")}async get(t,e){let s=new URL(`${this.baseUrl}${t}`);e&&Object.entries(e).forEach(([i,a])=>{a!==void 0&&s.searchParams.set(i,a)});let r=await this._fetchWithRetry(s.toString(),{headers:this.headers});return this._handle(r)}async post(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"POST",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async patch(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PATCH",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async put(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PUT",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async delete(t){let e=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"DELETE",headers:this.headers});return this._handle(e)}async uploadToPresignedUrl(t,e,s,r){for(let a=0;a<=3;a++)try{await new Promise((n,h)=>{let o=new XMLHttpRequest;o.open("PUT",t),o.setRequestHeader("Content-Type",s),r&&(o.upload.onprogress=p=>{p.lengthComputable&&r(Math.round(p.loaded/p.total*100))}),o.onload=()=>{o.status>=200&&o.status<300?n():o.status===429||o.status>=500?h(new Error(`Retryable network error: ${o.status}`)):h(new u(`Upload failed: ${o.status}`,o.status))},o.onerror=()=>h(new Error("Retryable network error")),o.send(e)});return}catch(n){if(n instanceof u||a>=3)throw n;let h=Math.min(1e3*Math.pow(2,a),1e4);console.warn(`[TelestackStorage] Upload chunk failed. Retrying in ${h}ms...`),await new Promise(o=>setTimeout(o,h))}}async _handle(t){let e=await t.json();if(!t.ok)throw new u(e.error||t.statusText,t.status,e.code);return e}},u=class extends Error{constructor(e,s,r){super(e);this.status=s;this.code=r;this.name="TelestackError"}};var d=class{static async generateKey(){let t=await crypto.subtle.generateKey({name:"AES-GCM",length:256},!0,["encrypt","decrypt"]),e=await crypto.subtle.exportKey("raw",t);return btoa(String.fromCharCode(...new Uint8Array(e)))}static async encrypt(t,e){let s=await this._importKey(e),r=crypto.getRandomValues(new Uint8Array(12)),i=await t.arrayBuffer(),a=await crypto.subtle.encrypt({name:"AES-GCM",iv:r},s,i);return new Blob([r,a],{type:"application/octet-stream"})}static async decrypt(t,e,s="application/octet-stream"){let r=await this._importKey(e),i=await t.arrayBuffer(),a=i.slice(0,12),n=i.slice(12),h=await crypto.subtle.decrypt({name:"AES-GCM",iv:new Uint8Array(a)},r,n);return new Blob([h],{type:s})}static async _importKey(t){let e=Uint8Array.from(atob(t),s=>s.charCodeAt(0));return crypto.subtle.importKey("raw",e,{name:"AES-GCM"},!1,["encrypt","decrypt"])}};var g=class{static async compress(t,e={}){if(!t.type.startsWith("image/"))return t;let{maxWidth:s=1920,maxHeight:r=1080,quality:i=.8}=e;return new Promise((a,n)=>{let h=new Image,o=URL.createObjectURL(t);h.onload=()=>{URL.revokeObjectURL(o);let p=h.width,c=h.height;p>s&&(c=Math.round(c*s/p),p=s),c>r&&(p=Math.round(p*r/c),c=r);let R=document.createElement("canvas");R.width=p,R.height=c;let P=R.getContext("2d");if(!P)return n(new Error("Failed to get canvas 2d context for image compression"));P.drawImage(h,0,0,p,c);let k=t.type==="image/png"?"image/png":"image/webp";R.toBlob(I=>{I?a(I):n(new Error("Canvas toBlob failed"))},k,i)},h.onerror=()=>{URL.revokeObjectURL(o),n(new Error("Failed to load image for compression"))},h.src=o})}};var m=class{constructor(t,e,s,r,i,a){this.client=t;this.path=e;this.name=r;this.contentType=i;this.options=a;this._data=s,this._totalBytes=s.size,this._promise=new Promise((n,h)=>{this._resolve=n,this._reject=h}),this._start()}_state="processing";_bytesTransferred=0;_totalBytes=0;_data;_observers=[];_promise;_resolve;_reject;_uploadId;_parts=[];_currentPartIndex=0;_activeXhr;_isResumable=!1;_simpleFileMetadata;on(t,e,s,r){let i=typeof e=="function"?{next:e,error:s,complete:r}:e||{error:s,complete:r};return this._observers.push(i),i.next&&i.next(this.snapshot),()=>{this._observers=this._observers.filter(a=>a!==i)}}pause(){return this._state!=="running"?!1:(this._state="paused",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._notifyObservers(),!0)}resume(){return this._state!=="paused"?!1:(this._state="running",this._notifyObservers(),this._isResumable?this._continueResumable().catch(this._handleError):(this._bytesTransferred=0,this._notifyObservers(),this._startSimple().catch(this._handleError)),!0)}cancel(){if(this._state==="success"||this._state==="error"||this._state==="canceled")return!1;this._state="canceled",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._isResumable&&this._uploadId?this.client.post("/files/resumable/abort",{path:this.path,uploadId:this._uploadId}).catch(()=>{}):!this._isResumable&&this._simpleFileMetadata&&this.client.delete(`/files/${encodeURIComponent(this.path)}`).catch(()=>{}),this._notifyObservers();let t=new Error("Upload canceled by user");return t.name="UploadCanceled",this._handleError(t),!0}get snapshot(){return{bytesTransferred:this._bytesTransferred,totalBytes:this._totalBytes,state:this._state,task:this}}then(t,e){return this._promise.then(t,e)}catch(t){return this._promise.catch(t)}finally(t){return this._promise.finally(t)}[Symbol.toStringTag]="UploadTask";async _start(){try{if(this.options.compressImage&&this._data.type.startsWith("image/")&&(this._data=await g.compress(this._data,this.options.compressImage)),this.options.encryptionKey&&(this._data=await d.encrypt(this._data,this.options.encryptionKey)),this._totalBytes=this._data.size,this._state!=="processing")return;this._state="running",this._notifyObservers();let t=50*1024*1024;this._isResumable=this._totalBytes>=t,this._isResumable?await this._startResumable():await this._startSimple()}catch(t){this._handleError(t)}}async _startSimple(){let t=await this.client.post("/files/upload-url",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});if(this._simpleFileMetadata=t.file,this._state!=="running")return;let e=new XMLHttpRequest;this._activeXhr=e,await new Promise((s,r)=>{e.open("PUT",t.uploadUrl),e.setRequestHeader("Content-Type",this.contentType),e.upload.onprogress=i=>{i.lengthComputable&&this._state==="running"&&(this._bytesTransferred=i.loaded,this._notifyObservers())},e.onload=()=>{e.status>=200&&e.status<300?s():r(new Error(`Upload failed: ${e.status} ${e.responseText}`))},e.onerror=()=>r(new Error("Network error during upload")),e.onabort=()=>r(new Error("Upload aborted")),e.send(this._data)}),this._activeXhr=void 0,this._state==="running"&&(await this.client.post("/files/complete-upload",{path:this.path}),this._bytesTransferred=this._totalBytes,this._state="success",this._notifyObservers(),this._resolve({file:t.file,resumable:!1}))}async _startResumable(){let t=await this.client.post("/files/resumable/init",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});this._uploadId=t.uploadId,this._simpleFileMetadata=t.file,await this._continueResumable()}async _continueResumable(){if(!this._uploadId||!this._simpleFileMetadata)return;let t=this.options.chunkSize??10*1024*1024,e=Math.ceil(this._totalBytes/t);try{for(;this._currentPartIndex<e;){if(this._state!=="running")return;let s=this._currentPartIndex*t,r=Math.min(s+t,this._totalBytes),i=this._data.slice(s,r),a=await this.client.post("/files/resumable/part-url",{path:this.path,uploadId:this._uploadId,partNumber:this._currentPartIndex+1});if(this._state!=="running")return;let n=await this._uploadChunkWithProgress(a.uploadUrl,i,s);this._parts.push({PartNumber:this._currentPartIndex+1,ETag:n}),this._currentPartIndex++,this._bytesTransferred=Math.min(this._currentPartIndex*t,this._totalBytes),this._notifyObservers()}this._state==="running"&&(await this.client.post("/files/resumable/complete",{path:this.path,uploadId:this._uploadId,parts:this._parts}),this._state="success",this._notifyObservers(),this._resolve({file:this._simpleFileMetadata,resumable:!0}))}catch(s){if(s.message==="Upload aborted")return;this._handleError(s)}}_uploadChunkWithProgress(t,e,s){return new Promise((r,i)=>{let a=new XMLHttpRequest;this._activeXhr=a,a.open("PUT",t),a.setRequestHeader("Content-Type",this.contentType),a.upload.onprogress=n=>{n.lengthComputable&&this._state==="running"&&(this._bytesTransferred=s+n.loaded,this._notifyObservers())},a.onload=()=>{if(a.status>=200&&a.status<300){let n=a.getResponseHeader("ETag")||"";r(n.replace(/"/g,""))}else i(new Error(`Part upload failed: ${a.status}`))},a.onerror=()=>i(new Error("Network error during part upload")),a.onabort=()=>i(new Error("Upload aborted")),a.send(e)})}_notifyObservers(){let t=this.snapshot;this._observers.forEach(e=>{t.state==="success"&&e.complete?e.complete():e.next&&e.next(t)})}_handleError=t=>{this._state!=="canceled"&&(this._state="error",this._activeXhr=void 0,this._notifyObservers(),this._observers.forEach(e=>e.error?.(t)),this._reject(t))}};var w=class{constructor(t,e,s){this.client=t;this.path=e;this.tenantId=s}child(t){let s=(this.path?this.path.endsWith("/")?this.path:this.path+"/":"")+t.replace(/^\//,"");return s.endsWith("/")?new y(this.client,s,this.tenantId):new f(this.client,s,this.tenantId)}},y=class extends w{async listAll(t=100){return(await this.client.get("/files",{prefix:this.path,limit:String(t)})).files}},f=class extends w{put(t,e){let s=(t instanceof File,t.size),r=t instanceof File?t.name:this.path.split("/").pop()||"file",i=t.type||"application/octet-stream";return new m(this.client,this.path,t,r,i,e)}putBytes(t,e,s){let r=this.path.split("/").pop()||"file",i=new Blob([t],{type:e});return new m(this.client,this.path,i,r,e,s)}async getDownloadUrl(t){return t?.versionId?(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t.versionId})).downloadUrl:(await this.client.get(`/files/download-url/${encodeURIComponent(this.path)}`)).downloadUrl}async getDecryptedBlob(t){let e=await this.getDownloadUrl(),s=await fetch(e);if(!s.ok)throw new Error(`Failed to download file from S3: ${s.status}`);let r=await s.blob(),i=await this.getMetadata();return await d.decrypt(r,t,i.content_type)}async getMetadata(){return(await this.client.get(`/files/metadata/${encodeURIComponent(this.path)}`)).file}async updateMetadata(t){return(await this.client.patch(`/files/metadata/${encodeURIComponent(this.path)}`,{metadata:t})).file}async delete(){await this.client.delete(`/files/${encodeURIComponent(this.path)}`)}async listVersions(){return this.client.get(`/files/versions/${encodeURIComponent(this.path)}`)}async getVersionUrl(t){return(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t})).downloadUrl}async getTags(){return(await this.client.get(`/files/tags/${encodeURIComponent(this.path)}`)).tags}async setTags(t){await this.client.put(`/files/tags/${encodeURIComponent(this.path)}`,{tags:t})}async setLegalHold(t){await this.client.post(`/files/legal-hold/${encodeURIComponent(this.path)}`,{status:t})}};var b=class{constructor(t){this.client=t}_op=null;_paths=[];_dest="";delete(t){return this._op="delete",this._paths=t,this}copy(t,e){return this._op="copy",this._paths=t,this._dest=e,this}move(t,e){return this._op="move",this._paths=t,this._dest=e,this}async run(){if(!this._op)throw new Error("No batch operation specified");if(this._op==="delete")return this.client.post("/files/batch/delete",{paths:this._paths});let t={sourcePaths:this._paths,destinationPrefix:this._dest};return this.client.post(`/files/batch/${this._op}`,t)}};var v=class{constructor(t){this.client=t}_query="";_metadata={};_limit=20;nameContains(t){return this._query=t,this}where(t,e){return this._metadata[t]=e,this}limit(t){return this._limit=t,this}async get(){let t={q:this._query||void 0,limit:String(this._limit),metadata:Object.keys(this._metadata).length>0?JSON.stringify(this._metadata):void 0};return(await this.client.get("/files/search",t)).files}};var T=class{constructor(t){this.config=t;this.http=new _(t),this.tenantId=t.tenantId}http;tenantId;ref(t){if(t.endsWith("/"))throw new Error("Use .dir() for directory references");return new f(this.http,t,this.tenantId)}dir(t){let e=t.endsWith("/")?t:t+"/";return new y(this.http,e,this.tenantId)}async list(t){let e={prefix:t?.prefix,limit:t?.limit!==void 0?String(t.limit):void 0};return(await this.http.get("/files",e)).files}batch(){return new b(this.http)}query(){return new v(this.http)}async generateApiKey(t){return this.http.post("/internal/keys/generate",{name:t})}async revokeApiKey(t){await this.http.post("/internal/keys/revoke",{keyId:t})}async getBucketInfo(){return this.http.get("/internal/bucket-info")}};return E(O);})();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var U=Object.defineProperty;var B=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var S=(l,t)=>{for(var e in t)U(l,e,{get:t[e],enumerable:!0})},M=(l,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of x(t))!C.call(l,r)&&r!==e&&U(l,r,{get:()=>t[r],enumerable:!(s=B(t,r))||s.enumerable});return l};var E=l=>M(U({},"__esModule",{value:!0}),l);var O={};S(O,{BatchBuilder:()=>b,CryptoHelper:()=>d,DirRef:()=>y,FileRef:()=>f,HttpClient:()=>_,ImageHelper:()=>g,QueryBuilder:()=>v,StorageRef:()=>w,TelestackError:()=>u,TelestackStorage:()=>T,UploadTask:()=>m});module.exports=E(O);var _=class{baseUrl;tenantId;headers;constructor(t){this.baseUrl=t.baseUrl.replace(/\/$/,""),this.tenantId=t.tenantId,this.headers={"Content-Type":"application/json","X-Tenant-ID":t.tenantId},t.apiKey?this.headers["X-API-Key"]=t.apiKey:t.token&&(this.headers.Authorization=`Bearer ${t.token}`)}async _fetchWithRetry(t,e,s=3){for(let r=0;r<=s;r++)try{let i=await fetch(t,e);if((i.status===429||i.status>=500)&&r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network error ${i.status}. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}return i}catch(i){if(r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network un-reachable. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}throw i}throw new Error("Unreachable code in retry logic")}async get(t,e){let s=new URL(`${this.baseUrl}${t}`);e&&Object.entries(e).forEach(([i,a])=>{a!==void 0&&s.searchParams.set(i,a)});let r=await this._fetchWithRetry(s.toString(),{headers:this.headers});return this._handle(r)}async post(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"POST",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async patch(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PATCH",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async put(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PUT",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async delete(t){let e=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"DELETE",headers:this.headers});return this._handle(e)}async uploadToPresignedUrl(t,e,s,r){for(let a=0;a<=3;a++)try{await new Promise((n,h)=>{let o=new XMLHttpRequest;o.open("PUT",t),o.setRequestHeader("Content-Type",s),r&&(o.upload.onprogress=p=>{p.lengthComputable&&r(Math.round(p.loaded/p.total*100))}),o.onload=()=>{o.status>=200&&o.status<300?n():o.status===429||o.status>=500?h(new Error(`Retryable network error: ${o.status}`)):h(new u(`Upload failed: ${o.status}`,o.status))},o.onerror=()=>h(new Error("Retryable network error")),o.send(e)});return}catch(n){if(n instanceof u||a>=3)throw n;let h=Math.min(1e3*Math.pow(2,a),1e4);console.warn(`[TelestackStorage] Upload chunk failed. Retrying in ${h}ms...`),await new Promise(o=>setTimeout(o,h))}}async _handle(t){let e=await t.json();if(!t.ok)throw new u(e.error||t.statusText,t.status,e.code);return e}},u=class extends Error{constructor(e,s,r){super(e);this.status=s;this.code=r;this.name="TelestackError"}};var d=class{static async generateKey(){let t=await crypto.subtle.generateKey({name:"AES-GCM",length:256},!0,["encrypt","decrypt"]),e=await crypto.subtle.exportKey("raw",t);return btoa(String.fromCharCode(...new Uint8Array(e)))}static async encrypt(t,e){let s=await this._importKey(e),r=crypto.getRandomValues(new Uint8Array(12)),i=await t.arrayBuffer(),a=await crypto.subtle.encrypt({name:"AES-GCM",iv:r},s,i);return new Blob([r,a],{type:"application/octet-stream"})}static async decrypt(t,e,s="application/octet-stream"){let r=await this._importKey(e),i=await t.arrayBuffer(),a=i.slice(0,12),n=i.slice(12),h=await crypto.subtle.decrypt({name:"AES-GCM",iv:new Uint8Array(a)},r,n);return new Blob([h],{type:s})}static async _importKey(t){let e=Uint8Array.from(atob(t),s=>s.charCodeAt(0));return crypto.subtle.importKey("raw",e,{name:"AES-GCM"},!1,["encrypt","decrypt"])}};var g=class{static async compress(t,e={}){if(!t.type.startsWith("image/"))return t;let{maxWidth:s=1920,maxHeight:r=1080,quality:i=.8}=e;return new Promise((a,n)=>{let h=new Image,o=URL.createObjectURL(t);h.onload=()=>{URL.revokeObjectURL(o);let p=h.width,c=h.height;p>s&&(c=Math.round(c*s/p),p=s),c>r&&(p=Math.round(p*r/c),c=r);let R=document.createElement("canvas");R.width=p,R.height=c;let P=R.getContext("2d");if(!P)return n(new Error("Failed to get canvas 2d context for image compression"));P.drawImage(h,0,0,p,c);let k=t.type==="image/png"?"image/png":"image/webp";R.toBlob(I=>{I?a(I):n(new Error("Canvas toBlob failed"))},k,i)},h.onerror=()=>{URL.revokeObjectURL(o),n(new Error("Failed to load image for compression"))},h.src=o})}};var m=class{constructor(t,e,s,r,i,a){this.client=t;this.path=e;this.name=r;this.contentType=i;this.options=a;this._data=s,this._totalBytes=s.size,this._promise=new Promise((n,h)=>{this._resolve=n,this._reject=h}),this._start()}_state="processing";_bytesTransferred=0;_totalBytes=0;_data;_observers=[];_promise;_resolve;_reject;_uploadId;_parts=[];_currentPartIndex=0;_activeXhr;_isResumable=!1;_simpleFileMetadata;on(t,e,s,r){let i=typeof e=="function"?{next:e,error:s,complete:r}:e||{error:s,complete:r};return this._observers.push(i),i.next&&i.next(this.snapshot),()=>{this._observers=this._observers.filter(a=>a!==i)}}pause(){return this._state!=="running"?!1:(this._state="paused",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._notifyObservers(),!0)}resume(){return this._state!=="paused"?!1:(this._state="running",this._notifyObservers(),this._isResumable?this._continueResumable().catch(this._handleError):(this._bytesTransferred=0,this._notifyObservers(),this._startSimple().catch(this._handleError)),!0)}cancel(){if(this._state==="success"||this._state==="error"||this._state==="canceled")return!1;this._state="canceled",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._isResumable&&this._uploadId?this.client.post("/files/resumable/abort",{path:this.path,uploadId:this._uploadId}).catch(()=>{}):!this._isResumable&&this._simpleFileMetadata&&this.client.delete(`/files/${encodeURIComponent(this.path)}`).catch(()=>{}),this._notifyObservers();let t=new Error("Upload canceled by user");return t.name="UploadCanceled",this._handleError(t),!0}get snapshot(){return{bytesTransferred:this._bytesTransferred,totalBytes:this._totalBytes,state:this._state,task:this}}then(t,e){return this._promise.then(t,e)}catch(t){return this._promise.catch(t)}finally(t){return this._promise.finally(t)}[Symbol.toStringTag]="UploadTask";async _start(){try{if(this.options.compressImage&&this._data.type.startsWith("image/")&&(this._data=await g.compress(this._data,this.options.compressImage)),this.options.encryptionKey&&(this._data=await d.encrypt(this._data,this.options.encryptionKey)),this._totalBytes=this._data.size,this._state!=="processing")return;this._state="running",this._notifyObservers();let t=50*1024*1024;this._isResumable=this._totalBytes>=t,this._isResumable?await this._startResumable():await this._startSimple()}catch(t){this._handleError(t)}}async _startSimple(){let t=await this.client.post("/files/upload-url",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});if(this._simpleFileMetadata=t.file,this._state!=="running")return;let e=new XMLHttpRequest;this._activeXhr=e,await new Promise((s,r)=>{e.open("PUT",t.uploadUrl),e.setRequestHeader("Content-Type",this.contentType),e.upload.onprogress=i=>{i.lengthComputable&&this._state==="running"&&(this._bytesTransferred=i.loaded,this._notifyObservers())},e.onload=()=>{e.status>=200&&e.status<300?s():r(new Error(`Upload failed: ${e.status} ${e.responseText}`))},e.onerror=()=>r(new Error("Network error during upload")),e.onabort=()=>r(new Error("Upload aborted")),e.send(this._data)}),this._activeXhr=void 0,this._state==="running"&&(await this.client.post("/files/complete-upload",{path:this.path}),this._bytesTransferred=this._totalBytes,this._state="success",this._notifyObservers(),this._resolve({file:t.file,resumable:!1}))}async _startResumable(){let t=await this.client.post("/files/resumable/init",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});this._uploadId=t.uploadId,this._simpleFileMetadata=t.file,await this._continueResumable()}async _continueResumable(){if(!this._uploadId||!this._simpleFileMetadata)return;let t=this.options.chunkSize??10*1024*1024,e=Math.ceil(this._totalBytes/t);try{for(;this._currentPartIndex<e;){if(this._state!=="running")return;let s=this._currentPartIndex*t,r=Math.min(s+t,this._totalBytes),i=this._data.slice(s,r),a=await this.client.post("/files/resumable/part-url",{path:this.path,uploadId:this._uploadId,partNumber:this._currentPartIndex+1});if(this._state!=="running")return;let n=await this._uploadChunkWithProgress(a.uploadUrl,i,s);this._parts.push({PartNumber:this._currentPartIndex+1,ETag:n}),this._currentPartIndex++,this._bytesTransferred=Math.min(this._currentPartIndex*t,this._totalBytes),this._notifyObservers()}this._state==="running"&&(await this.client.post("/files/resumable/complete",{path:this.path,uploadId:this._uploadId,parts:this._parts}),this._state="success",this._notifyObservers(),this._resolve({file:this._simpleFileMetadata,resumable:!0}))}catch(s){if(s.message==="Upload aborted")return;this._handleError(s)}}_uploadChunkWithProgress(t,e,s){return new Promise((r,i)=>{let a=new XMLHttpRequest;this._activeXhr=a,a.open("PUT",t),a.setRequestHeader("Content-Type",this.contentType),a.upload.onprogress=n=>{n.lengthComputable&&this._state==="running"&&(this._bytesTransferred=s+n.loaded,this._notifyObservers())},a.onload=()=>{if(a.status>=200&&a.status<300){let n=a.getResponseHeader("ETag")||"";r(n.replace(/"/g,""))}else i(new Error(`Part upload failed: ${a.status}`))},a.onerror=()=>i(new Error("Network error during part upload")),a.onabort=()=>i(new Error("Upload aborted")),a.send(e)})}_notifyObservers(){let t=this.snapshot;this._observers.forEach(e=>{t.state==="success"&&e.complete?e.complete():e.next&&e.next(t)})}_handleError=t=>{this._state!=="canceled"&&(this._state="error",this._activeXhr=void 0,this._notifyObservers(),this._observers.forEach(e=>e.error?.(t)),this._reject(t))}};var w=class{constructor(t,e,s){this.client=t;this.path=e;this.tenantId=s}child(t){let s=(this.path?this.path.endsWith("/")?this.path:this.path+"/":"")+t.replace(/^\//,"");return s.endsWith("/")?new y(this.client,s,this.tenantId):new f(this.client,s,this.tenantId)}},y=class extends w{async listAll(t=100){return(await this.client.get("/files",{prefix:this.path,limit:String(t)})).files}},f=class extends w{put(t,e){let s=(t instanceof File,t.size),r=t instanceof File?t.name:this.path.split("/").pop()||"file",i=t.type||"application/octet-stream";return new m(this.client,this.path,t,r,i,e)}putBytes(t,e,s){let r=this.path.split("/").pop()||"file",i=new Blob([t],{type:e});return new m(this.client,this.path,i,r,e,s)}async getDownloadUrl(t){return t?.versionId?(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t.versionId})).downloadUrl:(await this.client.get(`/files/download-url/${encodeURIComponent(this.path)}`)).downloadUrl}async getDecryptedBlob(t){let e=await this.getDownloadUrl(),s=await fetch(e);if(!s.ok)throw new Error(`Failed to download file from S3: ${s.status}`);let r=await s.blob(),i=await this.getMetadata();return await d.decrypt(r,t,i.content_type)}async getMetadata(){return(await this.client.get(`/files/metadata/${encodeURIComponent(this.path)}`)).file}async updateMetadata(t){return(await this.client.patch(`/files/metadata/${encodeURIComponent(this.path)}`,{metadata:t})).file}async delete(){await this.client.delete(`/files/${encodeURIComponent(this.path)}`)}async listVersions(){return this.client.get(`/files/versions/${encodeURIComponent(this.path)}`)}async getVersionUrl(t){return(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t})).downloadUrl}async getTags(){return(await this.client.get(`/files/tags/${encodeURIComponent(this.path)}`)).tags}async setTags(t){await this.client.put(`/files/tags/${encodeURIComponent(this.path)}`,{tags:t})}async setLegalHold(t){await this.client.post(`/files/legal-hold/${encodeURIComponent(this.path)}`,{status:t})}};var b=class{constructor(t){this.client=t}_op=null;_paths=[];_dest="";delete(t){return this._op="delete",this._paths=t,this}copy(t,e){return this._op="copy",this._paths=t,this._dest=e,this}move(t,e){return this._op="move",this._paths=t,this._dest=e,this}async run(){if(!this._op)throw new Error("No batch operation specified");if(this._op==="delete")return this.client.post("/files/batch/delete",{paths:this._paths});let t={sourcePaths:this._paths,destinationPrefix:this._dest};return this.client.post(`/files/batch/${this._op}`,t)}};var v=class{constructor(t){this.client=t}_query="";_metadata={};_limit=20;nameContains(t){return this._query=t,this}where(t,e){return this._metadata[t]=e,this}limit(t){return this._limit=t,this}async get(){let t={q:this._query||void 0,limit:String(this._limit),metadata:Object.keys(this._metadata).length>0?JSON.stringify(this._metadata):void 0};return(await this.client.get("/files/search",t)).files}};var T=class{constructor(t){this.config=t;this.http=new _(t),this.tenantId=t.tenantId}http;tenantId;ref(t){if(t.endsWith("/"))throw new Error("Use .dir() for directory references");return new f(this.http,t,this.tenantId)}dir(t){let e=t.endsWith("/")?t:t+"/";return new y(this.http,e,this.tenantId)}async list(t){let e={prefix:t?.prefix,limit:t?.limit!==void 0?String(t.limit):void 0};return(await this.http.get("/files",e)).files}batch(){return new b(this.http)}query(){return new v(this.http)}async generateApiKey(t){return this.http.post("/internal/keys/generate",{name:t})}async revokeApiKey(t){await this.http.post("/internal/keys/revoke",{keyId:t})}async getBucketInfo(){return this.http.get("/internal/bucket-info")}};0&&(module.exports={BatchBuilder,CryptoHelper,DirRef,FileRef,HttpClient,ImageHelper,QueryBuilder,StorageRef,TelestackError,TelestackStorage,UploadTask});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var _=class{baseUrl;tenantId;headers;constructor(t){this.baseUrl=t.baseUrl.replace(/\/$/,""),this.tenantId=t.tenantId,this.headers={"Content-Type":"application/json","X-Tenant-ID":t.tenantId},t.apiKey?this.headers["X-API-Key"]=t.apiKey:t.token&&(this.headers.Authorization=`Bearer ${t.token}`)}async _fetchWithRetry(t,e,s=3){for(let r=0;r<=s;r++)try{let i=await fetch(t,e);if((i.status===429||i.status>=500)&&r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network error ${i.status}. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}return i}catch(i){if(r<s){let a=Math.min(1e3*Math.pow(2,r),1e4);console.warn(`[TelestackStorage] Network un-reachable. Retrying in ${a}ms...`),await new Promise(n=>setTimeout(n,a));continue}throw i}throw new Error("Unreachable code in retry logic")}async get(t,e){let s=new URL(`${this.baseUrl}${t}`);e&&Object.entries(e).forEach(([i,a])=>{a!==void 0&&s.searchParams.set(i,a)});let r=await this._fetchWithRetry(s.toString(),{headers:this.headers});return this._handle(r)}async post(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"POST",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async patch(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PATCH",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async put(t,e){let s=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"PUT",headers:this.headers,body:JSON.stringify(e)});return this._handle(s)}async delete(t){let e=await this._fetchWithRetry(`${this.baseUrl}${t}`,{method:"DELETE",headers:this.headers});return this._handle(e)}async uploadToPresignedUrl(t,e,s,r){for(let a=0;a<=3;a++)try{await new Promise((n,l)=>{let o=new XMLHttpRequest;o.open("PUT",t),o.setRequestHeader("Content-Type",s),r&&(o.upload.onprogress=h=>{h.lengthComputable&&r(Math.round(h.loaded/h.total*100))}),o.onload=()=>{o.status>=200&&o.status<300?n():o.status===429||o.status>=500?l(new Error(`Retryable network error: ${o.status}`)):l(new u(`Upload failed: ${o.status}`,o.status))},o.onerror=()=>l(new Error("Retryable network error")),o.send(e)});return}catch(n){if(n instanceof u||a>=3)throw n;let l=Math.min(1e3*Math.pow(2,a),1e4);console.warn(`[TelestackStorage] Upload chunk failed. Retrying in ${l}ms...`),await new Promise(o=>setTimeout(o,l))}}async _handle(t){let e=await t.json();if(!t.ok)throw new u(e.error||t.statusText,t.status,e.code);return e}},u=class extends Error{constructor(e,s,r){super(e);this.status=s;this.code=r;this.name="TelestackError"}};var c=class{static async generateKey(){let t=await crypto.subtle.generateKey({name:"AES-GCM",length:256},!0,["encrypt","decrypt"]),e=await crypto.subtle.exportKey("raw",t);return btoa(String.fromCharCode(...new Uint8Array(e)))}static async encrypt(t,e){let s=await this._importKey(e),r=crypto.getRandomValues(new Uint8Array(12)),i=await t.arrayBuffer(),a=await crypto.subtle.encrypt({name:"AES-GCM",iv:r},s,i);return new Blob([r,a],{type:"application/octet-stream"})}static async decrypt(t,e,s="application/octet-stream"){let r=await this._importKey(e),i=await t.arrayBuffer(),a=i.slice(0,12),n=i.slice(12),l=await crypto.subtle.decrypt({name:"AES-GCM",iv:new Uint8Array(a)},r,n);return new Blob([l],{type:s})}static async _importKey(t){let e=Uint8Array.from(atob(t),s=>s.charCodeAt(0));return crypto.subtle.importKey("raw",e,{name:"AES-GCM"},!1,["encrypt","decrypt"])}};var g=class{static async compress(t,e={}){if(!t.type.startsWith("image/"))return t;let{maxWidth:s=1920,maxHeight:r=1080,quality:i=.8}=e;return new Promise((a,n)=>{let l=new Image,o=URL.createObjectURL(t);l.onload=()=>{URL.revokeObjectURL(o);let h=l.width,d=l.height;h>s&&(d=Math.round(d*s/h),h=s),d>r&&(h=Math.round(h*r/d),d=r);let R=document.createElement("canvas");R.width=h,R.height=d;let U=R.getContext("2d");if(!U)return n(new Error("Failed to get canvas 2d context for image compression"));U.drawImage(l,0,0,h,d);let I=t.type==="image/png"?"image/png":"image/webp";R.toBlob(P=>{P?a(P):n(new Error("Canvas toBlob failed"))},I,i)},l.onerror=()=>{URL.revokeObjectURL(o),n(new Error("Failed to load image for compression"))},l.src=o})}};var m=class{constructor(t,e,s,r,i,a){this.client=t;this.path=e;this.name=r;this.contentType=i;this.options=a;this._data=s,this._totalBytes=s.size,this._promise=new Promise((n,l)=>{this._resolve=n,this._reject=l}),this._start()}_state="processing";_bytesTransferred=0;_totalBytes=0;_data;_observers=[];_promise;_resolve;_reject;_uploadId;_parts=[];_currentPartIndex=0;_activeXhr;_isResumable=!1;_simpleFileMetadata;on(t,e,s,r){let i=typeof e=="function"?{next:e,error:s,complete:r}:e||{error:s,complete:r};return this._observers.push(i),i.next&&i.next(this.snapshot),()=>{this._observers=this._observers.filter(a=>a!==i)}}pause(){return this._state!=="running"?!1:(this._state="paused",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._notifyObservers(),!0)}resume(){return this._state!=="paused"?!1:(this._state="running",this._notifyObservers(),this._isResumable?this._continueResumable().catch(this._handleError):(this._bytesTransferred=0,this._notifyObservers(),this._startSimple().catch(this._handleError)),!0)}cancel(){if(this._state==="success"||this._state==="error"||this._state==="canceled")return!1;this._state="canceled",this._activeXhr&&(this._activeXhr.abort(),this._activeXhr=void 0),this._isResumable&&this._uploadId?this.client.post("/files/resumable/abort",{path:this.path,uploadId:this._uploadId}).catch(()=>{}):!this._isResumable&&this._simpleFileMetadata&&this.client.delete(`/files/${encodeURIComponent(this.path)}`).catch(()=>{}),this._notifyObservers();let t=new Error("Upload canceled by user");return t.name="UploadCanceled",this._handleError(t),!0}get snapshot(){return{bytesTransferred:this._bytesTransferred,totalBytes:this._totalBytes,state:this._state,task:this}}then(t,e){return this._promise.then(t,e)}catch(t){return this._promise.catch(t)}finally(t){return this._promise.finally(t)}[Symbol.toStringTag]="UploadTask";async _start(){try{if(this.options.compressImage&&this._data.type.startsWith("image/")&&(this._data=await g.compress(this._data,this.options.compressImage)),this.options.encryptionKey&&(this._data=await c.encrypt(this._data,this.options.encryptionKey)),this._totalBytes=this._data.size,this._state!=="processing")return;this._state="running",this._notifyObservers();let t=50*1024*1024;this._isResumable=this._totalBytes>=t,this._isResumable?await this._startResumable():await this._startSimple()}catch(t){this._handleError(t)}}async _startSimple(){let t=await this.client.post("/files/upload-url",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});if(this._simpleFileMetadata=t.file,this._state!=="running")return;let e=new XMLHttpRequest;this._activeXhr=e,await new Promise((s,r)=>{e.open("PUT",t.uploadUrl),e.setRequestHeader("Content-Type",this.contentType),e.upload.onprogress=i=>{i.lengthComputable&&this._state==="running"&&(this._bytesTransferred=i.loaded,this._notifyObservers())},e.onload=()=>{e.status>=200&&e.status<300?s():r(new Error(`Upload failed: ${e.status} ${e.responseText}`))},e.onerror=()=>r(new Error("Network error during upload")),e.onabort=()=>r(new Error("Upload aborted")),e.send(this._data)}),this._activeXhr=void 0,this._state==="running"&&(await this.client.post("/files/complete-upload",{path:this.path}),this._bytesTransferred=this._totalBytes,this._state="success",this._notifyObservers(),this._resolve({file:t.file,resumable:!1}))}async _startResumable(){let t=await this.client.post("/files/resumable/init",{path:this.path,name:this.name,size:this._totalBytes,contentType:this.contentType,userId:this.options.userId,metadata:this.options.metadata});this._uploadId=t.uploadId,this._simpleFileMetadata=t.file,await this._continueResumable()}async _continueResumable(){if(!this._uploadId||!this._simpleFileMetadata)return;let t=this.options.chunkSize??10*1024*1024,e=Math.ceil(this._totalBytes/t);try{for(;this._currentPartIndex<e;){if(this._state!=="running")return;let s=this._currentPartIndex*t,r=Math.min(s+t,this._totalBytes),i=this._data.slice(s,r),a=await this.client.post("/files/resumable/part-url",{path:this.path,uploadId:this._uploadId,partNumber:this._currentPartIndex+1});if(this._state!=="running")return;let n=await this._uploadChunkWithProgress(a.uploadUrl,i,s);this._parts.push({PartNumber:this._currentPartIndex+1,ETag:n}),this._currentPartIndex++,this._bytesTransferred=Math.min(this._currentPartIndex*t,this._totalBytes),this._notifyObservers()}this._state==="running"&&(await this.client.post("/files/resumable/complete",{path:this.path,uploadId:this._uploadId,parts:this._parts}),this._state="success",this._notifyObservers(),this._resolve({file:this._simpleFileMetadata,resumable:!0}))}catch(s){if(s.message==="Upload aborted")return;this._handleError(s)}}_uploadChunkWithProgress(t,e,s){return new Promise((r,i)=>{let a=new XMLHttpRequest;this._activeXhr=a,a.open("PUT",t),a.setRequestHeader("Content-Type",this.contentType),a.upload.onprogress=n=>{n.lengthComputable&&this._state==="running"&&(this._bytesTransferred=s+n.loaded,this._notifyObservers())},a.onload=()=>{if(a.status>=200&&a.status<300){let n=a.getResponseHeader("ETag")||"";r(n.replace(/"/g,""))}else i(new Error(`Part upload failed: ${a.status}`))},a.onerror=()=>i(new Error("Network error during part upload")),a.onabort=()=>i(new Error("Upload aborted")),a.send(e)})}_notifyObservers(){let t=this.snapshot;this._observers.forEach(e=>{t.state==="success"&&e.complete?e.complete():e.next&&e.next(t)})}_handleError=t=>{this._state!=="canceled"&&(this._state="error",this._activeXhr=void 0,this._notifyObservers(),this._observers.forEach(e=>e.error?.(t)),this._reject(t))}};var w=class{constructor(t,e,s){this.client=t;this.path=e;this.tenantId=s}child(t){let s=(this.path?this.path.endsWith("/")?this.path:this.path+"/":"")+t.replace(/^\//,"");return s.endsWith("/")?new y(this.client,s,this.tenantId):new f(this.client,s,this.tenantId)}},y=class extends w{async listAll(t=100){return(await this.client.get("/files",{prefix:this.path,limit:String(t)})).files}},f=class extends w{put(t,e){let s=(t instanceof File,t.size),r=t instanceof File?t.name:this.path.split("/").pop()||"file",i=t.type||"application/octet-stream";return new m(this.client,this.path,t,r,i,e)}putBytes(t,e,s){let r=this.path.split("/").pop()||"file",i=new Blob([t],{type:e});return new m(this.client,this.path,i,r,e,s)}async getDownloadUrl(t){return t?.versionId?(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t.versionId})).downloadUrl:(await this.client.get(`/files/download-url/${encodeURIComponent(this.path)}`)).downloadUrl}async getDecryptedBlob(t){let e=await this.getDownloadUrl(),s=await fetch(e);if(!s.ok)throw new Error(`Failed to download file from S3: ${s.status}`);let r=await s.blob(),i=await this.getMetadata();return await c.decrypt(r,t,i.content_type)}async getMetadata(){return(await this.client.get(`/files/metadata/${encodeURIComponent(this.path)}`)).file}async updateMetadata(t){return(await this.client.patch(`/files/metadata/${encodeURIComponent(this.path)}`,{metadata:t})).file}async delete(){await this.client.delete(`/files/${encodeURIComponent(this.path)}`)}async listVersions(){return this.client.get(`/files/versions/${encodeURIComponent(this.path)}`)}async getVersionUrl(t){return(await this.client.get(`/files/version-url/${encodeURIComponent(this.path)}`,{versionId:t})).downloadUrl}async getTags(){return(await this.client.get(`/files/tags/${encodeURIComponent(this.path)}`)).tags}async setTags(t){await this.client.put(`/files/tags/${encodeURIComponent(this.path)}`,{tags:t})}async setLegalHold(t){await this.client.post(`/files/legal-hold/${encodeURIComponent(this.path)}`,{status:t})}};var b=class{constructor(t){this.client=t}_op=null;_paths=[];_dest="";delete(t){return this._op="delete",this._paths=t,this}copy(t,e){return this._op="copy",this._paths=t,this._dest=e,this}move(t,e){return this._op="move",this._paths=t,this._dest=e,this}async run(){if(!this._op)throw new Error("No batch operation specified");if(this._op==="delete")return this.client.post("/files/batch/delete",{paths:this._paths});let t={sourcePaths:this._paths,destinationPrefix:this._dest};return this.client.post(`/files/batch/${this._op}`,t)}};var v=class{constructor(t){this.client=t}_query="";_metadata={};_limit=20;nameContains(t){return this._query=t,this}where(t,e){return this._metadata[t]=e,this}limit(t){return this._limit=t,this}async get(){let t={q:this._query||void 0,limit:String(this._limit),metadata:Object.keys(this._metadata).length>0?JSON.stringify(this._metadata):void 0};return(await this.client.get("/files/search",t)).files}};var T=class{constructor(t){this.config=t;this.http=new _(t),this.tenantId=t.tenantId}http;tenantId;ref(t){if(t.endsWith("/"))throw new Error("Use .dir() for directory references");return new f(this.http,t,this.tenantId)}dir(t){let e=t.endsWith("/")?t:t+"/";return new y(this.http,e,this.tenantId)}async list(t){let e={prefix:t?.prefix,limit:t?.limit!==void 0?String(t.limit):void 0};return(await this.http.get("/files",e)).files}batch(){return new b(this.http)}query(){return new v(this.http)}async generateApiKey(t){return this.http.post("/internal/keys/generate",{name:t})}async revokeApiKey(t){await this.http.post("/internal/keys/revoke",{keyId:t})}async getBucketInfo(){return this.http.get("/internal/bucket-info")}};export{b as BatchBuilder,c as CryptoHelper,y as DirRef,f as FileRef,_ as HttpClient,g as ImageHelper,v as QueryBuilder,w as StorageRef,u as TelestackError,T as TelestackStorage,m as UploadTask};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telestack/storage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official Telestack Storage Web SDK — fluent, Firebase-killer, real-time cloud storage for web apps.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"unpkg": "./dist/index.global.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format cjs,esm,iife --global-name TelestackStorageSetup --minify --dts --clean"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"telestack",
|
|
20
|
+
"storage",
|
|
21
|
+
"sdk",
|
|
22
|
+
"cloud",
|
|
23
|
+
"file-upload"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/jsdom": "^27.0.0",
|
|
28
|
+
"dotenv": "^17.3.1",
|
|
29
|
+
"jose": "^6.1.3",
|
|
30
|
+
"jsdom": "^28.1.0",
|
|
31
|
+
"node-fetch": "^3.3.2",
|
|
32
|
+
"ts-node": "^10.9.2",
|
|
33
|
+
"tsup": "^8.0.0",
|
|
34
|
+
"typescript": "^5.0.0",
|
|
35
|
+
"xhr2": "^0.2.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { HttpClient } from './HttpClient';
|
|
2
|
+
import { BatchDeleteResult, BatchCopyResult } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fluent builder for batch file operations.
|
|
6
|
+
* Operations execute identically as single network requests over the backend API.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* await storage.batch().delete(['a.pdf', 'b.pdf']).run();
|
|
10
|
+
* await storage.batch().copy(['a.pdf'], 'archive/').run();
|
|
11
|
+
* await storage.batch().move(['a.pdf'], 'archive/').run();
|
|
12
|
+
*/
|
|
13
|
+
export class BatchBuilder {
|
|
14
|
+
private _op: 'delete' | 'copy' | 'move' | null = null;
|
|
15
|
+
private _paths: string[] = [];
|
|
16
|
+
private _dest = '';
|
|
17
|
+
|
|
18
|
+
constructor(private readonly client: HttpClient) { }
|
|
19
|
+
|
|
20
|
+
delete(paths: string[]): this {
|
|
21
|
+
this._op = 'delete';
|
|
22
|
+
this._paths = paths;
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
copy(paths: string[], destinationPrefix: string): this {
|
|
27
|
+
this._op = 'copy';
|
|
28
|
+
this._paths = paths;
|
|
29
|
+
this._dest = destinationPrefix;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
move(paths: string[], destinationPrefix: string): this {
|
|
34
|
+
this._op = 'move';
|
|
35
|
+
this._paths = paths;
|
|
36
|
+
this._dest = destinationPrefix;
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async run(): Promise<BatchDeleteResult | BatchCopyResult> {
|
|
41
|
+
if (!this._op) throw new Error('No batch operation specified');
|
|
42
|
+
if (this._op === 'delete') {
|
|
43
|
+
return this.client.post<BatchDeleteResult>('/files/batch/delete', { paths: this._paths });
|
|
44
|
+
}
|
|
45
|
+
const body = { sourcePaths: this._paths, destinationPrefix: this._dest };
|
|
46
|
+
return this.client.post<BatchCopyResult>(`/files/batch/${this._op}`, body);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// TelestackStorage Web SDK — CryptoHelper (E2EE)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export class CryptoHelper {
|
|
6
|
+
/**
|
|
7
|
+
* Generates a new random AES-GCM 256-bit encryption key.
|
|
8
|
+
* Returns the key as a base64 encoded string.
|
|
9
|
+
*/
|
|
10
|
+
static async generateKey(): Promise<string> {
|
|
11
|
+
const key = await crypto.subtle.generateKey(
|
|
12
|
+
{ name: 'AES-GCM', length: 256 },
|
|
13
|
+
true, // extractable
|
|
14
|
+
['encrypt', 'decrypt']
|
|
15
|
+
);
|
|
16
|
+
const exported = await crypto.subtle.exportKey('raw', key);
|
|
17
|
+
return btoa(String.fromCharCode(...new Uint8Array(exported)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encrypts a Blob using AES-GCM.
|
|
22
|
+
* Prepends a random 12-byte IV to the resulting ciphertext Blob.
|
|
23
|
+
*/
|
|
24
|
+
static async encrypt(blob: Blob, keyBase64: string): Promise<Blob> {
|
|
25
|
+
const key = await this._importKey(keyBase64);
|
|
26
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
27
|
+
|
|
28
|
+
const buffer = await blob.arrayBuffer();
|
|
29
|
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
|
30
|
+
{ name: 'AES-GCM', iv },
|
|
31
|
+
key,
|
|
32
|
+
buffer
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Prepend the 12-byte IV so we can decrypt it later
|
|
36
|
+
return new Blob([iv, encryptedBuffer], { type: 'application/octet-stream' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decrypts a Blob that was encrypted with `encrypt()`.
|
|
41
|
+
* Extracts the 12-byte IV from the front and decrypts the rest.
|
|
42
|
+
* Restores the original MIME type.
|
|
43
|
+
*/
|
|
44
|
+
static async decrypt(blob: Blob, keyBase64: string, originalMimeType: string = 'application/octet-stream'): Promise<Blob> {
|
|
45
|
+
const key = await this._importKey(keyBase64);
|
|
46
|
+
|
|
47
|
+
const buffer = await blob.arrayBuffer();
|
|
48
|
+
|
|
49
|
+
// Extract the 12-byte IV from the prefix
|
|
50
|
+
const iv = buffer.slice(0, 12);
|
|
51
|
+
const ciphertext = buffer.slice(12);
|
|
52
|
+
|
|
53
|
+
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
54
|
+
{ name: 'AES-GCM', iv: new Uint8Array(iv) },
|
|
55
|
+
key,
|
|
56
|
+
ciphertext
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return new Blob([decryptedBuffer], { type: originalMimeType });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static async _importKey(keyBase64: string): Promise<CryptoKey> {
|
|
63
|
+
const keyBuffer = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
|
|
64
|
+
return crypto.subtle.importKey(
|
|
65
|
+
'raw',
|
|
66
|
+
keyBuffer,
|
|
67
|
+
{ name: 'AES-GCM' },
|
|
68
|
+
false, // no need to extract it again
|
|
69
|
+
['encrypt', 'decrypt']
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|