UNPKG

13.1 kBPlain TextView Raw
1import { Bucket as GoogleBucket } from '@google-cloud/storage';
2import { S3 } from 'aws-sdk';
3import { glob, mkdirp } from 'fs-extra-plus';
4import { lookup } from 'mime-types';
5import * as Path from 'path';
6import { Readable, Writable } from 'stream';
7import { Driver, ListCloudFilesOptions } from './driver';
8import { BucketFile, BucketFileDeleted, BucketType, ListArg, ListOptions } from './types';
9
10interface BucketOptions {
11 driver: Driver;
12}
13
14export function newBucket(opts: BucketOptions) {
15 return new BucketImpl(opts);
16}
17
18export interface Bucket {
19 type: BucketType;
20 name: string;
21
22 readonly s3?: S3;
23 readonly googleBucket?: GoogleBucket;
24
25
26 exists(path: string): Promise<boolean>;
27
28 /**
29 * Get and return a BucketFile for a given path (will do a cloud bucket query).
30 * Returns null if not found. Throw exception if any other exception than notfound.
31 */
32 getFile(path: String): Promise<BucketFile | null>;
33
34 list(optsOrPrefix?: ListArg): Promise<BucketFile[]>;
35
36 /**
37 * Will copy one or more file to a destination file (then require single match, i.e. no glob),
38 * or to a destination folder.
39 * @param prefixOrGlob full file name, or prefix or glob. If multiple match, to need dest must be a dir (end with '/')
40 * @param dest can be dir path (copying multiple file and preserving filename and relative dir structure), or full file name (require single match)
41 */
42 copy(prefixOrGlob: string, dest: string | BucketFile): Promise<void>;
43
44 /**
45 * Download one or more remote bucket file to a local file or a folder structure.
46 * @param prefixOrGlob
47 * @param localDir
48 * If end with '/' then all files from the prefixOrGlob will be downloaded with their originial filename (and relative folder structure).
49 * Otherwise, if full file name, then, make sure there is onyl one matching bucket source, and copy to this file destination (to rename on download)
50 */
51 download(prefixOrGlob: string, localPath: string): Promise<BucketFile[]>
52
53 downloadAsText(path: string): Promise<string>
54
55 upload(localPath: string, path: string): Promise<BucketFile[]>;
56
57 uploadContent(path: string, content: string): Promise<void>;
58
59 createReadStream(path: string): Promise<Readable>;
60
61 createWriteStream(path: string): Promise<Writable>;
62
63 /**
64 * Delete a single file.
65 * @param path
66 */
67 delete(path: string): Promise<boolean>
68
69 deleteAll(files: BucketFile[]): Promise<BucketFileDeleted[]>
70
71}
72
73
74class BucketImpl<F> implements Bucket {
75 driver: Driver<F>;
76 constructor(opts: BucketOptions) {
77 this.driver = opts.driver
78 }
79
80 get type() {
81 return this.driver.type;
82 }
83 get name() {
84 return this.driver.name;
85 }
86
87 get s3(): S3 | undefined {
88 if (this.driver.type === 's3') {
89 return (<any>this.driver).s3 as S3;
90 }
91 }
92
93 get googleBucket(): GoogleBucket | undefined {
94 if (this.driver.type === 'gs') {
95 return (<any>this.driver).googleBucket as GoogleBucket;
96 }
97 }
98
99 toFile(cf: F): BucketFile {
100 const bucketFile = this.driver.toFile(cf);
101 (<BucketFile>bucketFile).bucket = this; // driver does not know/add bucket property
102 return bucketFile as BucketFile;
103 }
104
105
106 async exists(path: string): Promise<boolean> {
107 return this.driver.exists(path);
108 }
109
110
111 /**
112 * Get and return a BucketFile for a given path (will do a cloud bucket query).
113 * Returns null if not found. Throw exception if any other exception than notfound.
114 */
115 async getFile(path: String): Promise<BucketFile | null> {
116 const cloudFile = await this.driver.getCloudFile(path);
117 return (cloudFile == null) ? null : this.toFile(cloudFile);
118 }
119
120 async list(optsOrPrefix?: ListArg): Promise<BucketFile[]> {
121 const cloudFiles = await this.driver.listCloudFiles(parseListOptions(optsOrPrefix));
122 const bucketFiles = cloudFiles.map(cf => this.toFile(cf));
123 return bucketFiles;
124 }
125
126 /**
127 * Will copy one or more file to a destination file (then require single match, i.e. no glob),
128 * or to a destination folder.
129 * @param prefixOrGlob full file name, or prefix or glob. If multiple match, to need dest must be a dir (end with '/')
130 * @param dest can be dir path (copying multiple file and preserving filename and relative dir structure), or full file name (require single match)
131 */
132 async copy(prefixOrGlob: string, dest: string | BucketFile): Promise<void> {
133 // return this.driver.copy(prefixOrGlob, dest);
134 const cloudFiles = await this.driver.listCloudFiles(parseListOptions(prefixOrGlob));
135
136 const destBucket = (typeof dest === 'string') ? this : dest.bucket;
137 const destPath = (typeof dest === 'string') ? dest : dest.path;
138
139 const isDestPathDir = destPath.endsWith('/');
140
141 // If not a local directory, make sure we have only one file.
142 // TODO: might check if the pathOrGlob is a glob as well to prevent it (in case there is only one match)
143 if (!isDestPathDir && cloudFiles.length > 1) {
144 throw new Error(`Cannot copy multiple files ${prefixOrGlob} to the same bucket file ${destPath}. Download to a directory (end with '/') to download multipel file.`);
145 }
146
147 const { baseDir } = parsePrefixOrGlob(prefixOrGlob);
148 const files: BucketFile[] = [];
149
150 for (let cf of cloudFiles) {
151 const remotePath = this.driver.getPath(cf);
152 const destFilePath = (isDestPathDir) ? getDestPath(baseDir, remotePath, destPath) : destPath;
153
154 process.stdout.write(`Copying ${this.type}://${this.name}/${remotePath} to ${destBucket.type}://${destBucket.name}/${destFilePath}`);
155
156 try {
157 await this.driver.copyCloudFile(cf, { bucket: destBucket, path: destFilePath });
158 process.stdout.write(` - DONE\n`);
159
160 } catch (ex) {
161 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
162 throw ex;
163 }
164 }
165 // return files;
166 }
167
168 /**
169 * Download one or more remote bucket file to a local file or a folder structure.
170 * @param prefixOrGlob
171 * @param localDir
172 * If end with '/' then all files from the prefixOrGlob will be downloaded with their originial filename (and relative folder structure).
173 * Otherwise, if full file name, then, make sure there is onyl one matching bucket source, and copy to this file destination (to rename on download)
174 */
175 async download(prefixOrGlob: string, localPath: string): Promise<BucketFile[]> {
176 const isLocalPathDir = localPath.endsWith('/');
177
178 const cloudFiles = await this.driver.listCloudFiles(parseListOptions(prefixOrGlob));
179
180 // If not a local directory, make sure we have only one file.
181 // TODO: might check if the pathOrGlob is a glob as well to prevent it (in case there is only one match)
182 if (!isLocalPathDir && cloudFiles.length > 1) {
183 throw new Error(`Cannot copy multiple files ${prefixOrGlob} to the same local file ${localPath}. Download to a directory (end with '/') to download multipel file.`);
184 }
185 const files: BucketFile[] = [];
186 const { baseDir } = parsePrefixOrGlob(prefixOrGlob);
187
188 for (let cf of cloudFiles) {
189 const remotePath = this.driver.getPath(cf);
190
191 const localFilePath = (isLocalPathDir) ? getDestPath(baseDir, remotePath, localPath) : localPath;
192
193 const localPathDir = Path.dirname(localFilePath);
194 await mkdirp(localPathDir);
195 process.stdout.write(`Downloading ${this.type}://${this.name}/${remotePath} to ${localFilePath}`);
196
197 try {
198 await this.driver.downloadCloudFile(cf, localFilePath)
199 process.stdout.write(` - DONE\n`);
200 const file = { bucket: this, path: remotePath, size: -1, local: localFilePath };
201 files.push(file);
202 } catch (ex) {
203 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
204 throw ex;
205 }
206 }
207
208 return files;
209 }
210
211 downloadAsText(path: string): Promise<string> {
212 return this.driver.downloadAsText(path);
213 }
214
215 async upload(localPath: string, remotePath: string): Promise<BucketFile[]> {
216 const bucketFiles: BucketFile[] = [];
217
218 if (localPath.endsWith('/')) {
219 localPath = localPath + '**/*.*';
220 }
221 const isLocalGlob = localPath.includes('*');
222
223 const { baseDir } = parsePrefixOrGlob(localPath);
224
225 const localFiles = await glob(localPath);
226
227 for (const localPath of localFiles) {
228 // if we have an localFileExpression (globs), then, we build the fullDestPath relative to the baseDir of the glob (last / before the first *)
229 const fullDestPath = (isLocalGlob) ? getDestPath(baseDir, localPath, remotePath) : buildFullDestPath(localPath, remotePath);
230 const contentType = getContentType(localPath);
231 process.stdout.write(`Uploading file ${localPath} to ${this.type}://${this.name}/${fullDestPath}`);
232 try {
233 const cloudFile = await this.driver.uploadCloudFile(localPath, fullDestPath, contentType);
234 const bucketFile = this.toFile(cloudFile);
235 bucketFiles.push(bucketFile);
236 process.stdout.write(` - DONE\n`);
237 } catch (ex) {
238 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
239 throw ex;
240 }
241 }
242
243
244 return bucketFiles;
245 }
246
247 uploadContent(path: string, content: string): Promise<void> {
248 const contentType = getContentType(path);
249 return this.driver.uploadCloudContent(path, content, contentType);
250 }
251
252 createReadStream(path: string): Promise<Readable> {
253 return this.driver.createReadStream(path);
254 }
255
256 createWriteStream(path: string): Promise<Writable> {
257 return this.driver.createWriteStream(path);
258 }
259
260 /**
261 * Delete a single file.
262 * @param path
263 */
264 async delete(path: string): Promise<boolean> {
265 let deleted = false;
266 if (!path) {
267 throw new Error(`ERROR - Can't delete null or empty path`);
268 }
269 try {
270 process.stdout.write(`Deleting ${this.type}://${this.name}/${path}`);
271 deleted = await this.driver.deleteCloudFile(path);
272 process.stdout.write(` - DONE\n`);
273 } catch (ex) {
274 throw new Error(`ERROR - cloud-bucket - Cannot delete ${path} for bucket ${this.name}. Cause: ${ex}`);
275 }
276 return deleted;
277 }
278
279 async deleteAll(files: BucketFile[]): Promise<BucketFileDeleted[]> {
280 const filesInfo: BucketFileDeleted[] = [];
281
282 // validate that all files are same bucket
283 for (const file of files) {
284 // check if same bucket
285 if (file.bucket !== this) {
286 throw new Error(`Cannot delete file from another bucket ${this.name} should match file bucket ${file.bucket.name}`);
287 }
288 }
289
290 for (const file of files) {
291 const deleted = await this.driver.deleteCloudFile(file.path);
292 filesInfo.push({ ...file, deleted })
293 }
294
295 return filesInfo;
296 }
297
298}
299
300
301//#region ---------- Utils ----------
302function getDestPath(baseDir: string | undefined, remotePath: string, destPathDir: string) {
303 const baseName = Path.basename(remotePath);
304 const filePath = (baseDir) ? Path.relative(baseDir, remotePath) : baseName;
305 const destPath = `${destPathDir}${filePath}`;
306 return destPath;
307}
308
309/**
310 * Build the full destination path from the local path name and the destPath
311 * - If `destPath` ends with `/`, then baseName of `localPath` is concatenated.
312 * - Otherwise, `destPath` is the fullDestPath.
313 *
314 * @throws exception if destPath is not present.
315 */
316export function buildFullDestPath(localPath: string, destPath: string) {
317
318 // we do not have a dest path, throw error
319 if (!destPath) {
320 throw new Error('No depthPath');
321 }
322
323 let fullDestPath: string;
324
325 // if it is a folder, we just concatinate the base name
326 if (destPath.endsWith('/')) {
327 const srcBaseName = Path.basename(localPath);
328 fullDestPath = destPath + srcBaseName;
329 }
330 // if the destPath is not a folder, assume it is the new file name.
331 else {
332 fullDestPath = destPath;
333 }
334
335 return fullDestPath;
336}
337
338
339export function getContentType(path: string) {
340 let ct = lookup(path);
341 let contentType = (ct) ? ct : undefined;
342 return contentType;
343
344}
345
346
347export function parseListOptions(optsOrPrefix?: ListOptions | string): ListCloudFilesOptions {
348 const { prefix, glob } = (typeof optsOrPrefix === 'string') ? parsePrefixOrGlob(optsOrPrefix) : parsePrefixOrGlob(optsOrPrefix?.prefix);
349 return { prefix, glob, delimiter: (typeof optsOrPrefix !== 'string') ? optsOrPrefix?.delimiter : undefined }
350}
351/**
352 * Return a clean prefix and glob when defined in the string. Clean prefix, meaning, glob less one,
353 * that can be passed to most cloud storage api.
354 *
355 * @param prefixOrGlob undefined, null, e.g., 'some-prefix', 'folder/', 'folder/glob-pattern.*'
356 * @returns {prefix, glob, baseDir}
357 * - prefix is the first characters until the first glob character ('*')
358 * - glob is prefixOrGlob value if it is a glob, otherwise undefined.
359 * - baseDir is the eventual longest directory path without any glob char (ending with '/')
360 */
361export function parsePrefixOrGlob(prefixOrGlob?: string) {
362 let glob: string | undefined;
363 let prefix: string | undefined;
364 let baseDir: string | undefined;
365
366 if (prefixOrGlob && prefixOrGlob.length > 0) {
367 const firstWildIdx = prefixOrGlob.indexOf('*');
368 // if it has a '*' then it is a pattern
369 if (firstWildIdx > 0) {
370 glob = prefixOrGlob;
371 prefix = prefixOrGlob.substring(0, firstWildIdx);
372 }
373 // otherwise, it is just a
374 else {
375 prefix = prefixOrGlob;
376 }
377 }
378
379 if (prefix) {
380 const lastSlashIdx = prefix.lastIndexOf('/');
381 if (lastSlashIdx > -1) {
382 baseDir = prefix.substring(0, lastSlashIdx + 1);
383 }
384 }
385
386 return { prefix, glob, baseDir };
387}
388//#endregion ---------- /Utils ----------
\No newline at end of file